docs(18.1): map file patterns to existing analogs

This commit is contained in:
Jakub Zych
2026-10-14 13:30:39 +02:00
parent 0849f98041
commit b1ed92490d

View File

@@ -0,0 +1,309 @@
# Phase 18.1: Payment Gateway Subscriptions - Pattern Map
**Mapped:** 2026-10-14
**Files analyzed:** 38 (grouped by the D-22 six-plan split)
**Analogs found:** 36 / 38
All paths are git-tracked. Repo roots (siblings under `/media/nvme/dev/golem15/summercms.io/summercms/`):
`summercms.go/` (FW), `sm-paymentgateway-plugin/` (PG), `sm-pgstripe-plugin/` (PGS), `vue-paymentgateway-layer/` (LAYER), new `sm-paymentgatewaysubscriptions-plugin/` (PGSUB). `sm-user-plugin` is cited through its tracked checkout `sm-summercmsio-app/plugins/golem15/user/` (submodule, origin `sm-user-plugin.git`; PGS `go.work` replaces it from there).
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match |
|---|---|---|---|---|
| **Plan 1 (FW)** | | | | |
| `FW/modules/cabana/reorder.go` (flat mode) | controller | request-response, CRUD | same file (tree reorder) | exact (extend) |
| `FW/modules/cabana/field_taglist.go` (new) | utility/schema | transform | `FW/modules/cabana/field_money.go`, `field_repeater.go` | role-match |
| `FW/modules/cabana/form_schema.go`, `crud.go`, `schema_types.go` | config | transform | money registration lines | exact |
| `FW/modules/cabana/admin_openapi.go` | config (swag docs) | - | `AdminReorderGet`/`AdminReorderSave` lines 486-520 | exact |
| `FW/admin/src/components/form/fields/TaglistField.vue` | component | event-driven | `MoneyField.vue` | role-match |
| `FW/admin/src/components/form/registry.ts` | config | - | money entry | exact |
| `FW/admin/src/views/ReorderView.vue`, `components/reorder/tree.ts` (flat mode) | component | request-response | same files | exact (extend) |
| `FW/admin/tests/form/TaglistField.test.ts`, `tests/views/ReorderView.test.ts` | test | - | `admin/tests/form/MoneyField.test.ts`, `tests/views/ReorderView.test.ts` | exact |
| `FW/modules/cabana/taglist_field_test.go`, `reorder_test.go` | test | - | `money_field_test.go`, `reorder_test.go` | exact |
| `FW/docs/backend/forms.md` (Taglist), `docs/backend/lists-and-filters.md` (flat reorder) | docs | - | `## Money` section (forms.md:463), `## Tree reorder` (lists-and-filters.md:190) | exact |
| `FW/admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, `modules/boardwalk/dist` | generated | - | `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh` | exact |
| **Plan 2 (PGSUB core)** | | | | |
| `PGSUB/plugin.go` | provider (party plugin) | - | `PG/plugin.go` | exact |
| `PGSUB/boot.go` (registries) | provider | event-driven (boot) | `PG/boot.go` `bootOperators` | exact |
| `PGSUB/contracts/registry.go` (subscribers, gateways) | utility | - | `PG/operators/registry.go` | exact |
| `PGSUB/contracts/*.go` (Gateway, Subscriber) | model/interface | - | `PG/operators/operator.go` + RESEARCH Pattern 1 | role-match |
| `PGSUB/models/subscription.go`, `subscription_plan.go`, `processed_webhook_event.go`, `registry.go` | model | CRUD | `PG/models/payment.go`, `PG/models/registry.go`, `PGS/models/settings.go` | role-match |
| `PGSUB/models/*/fields.yaml`, `columns.yaml` | config | - | `PG/models/payment/*.yaml` | exact |
| `PGSUB/events/events.go` | model (event types) | pub-sub | `PG/events/events.go` | exact |
| `PGSUB/updates/*.go` (baseline + users.stripe_customer_id) | migration | - | `PG/updates/registry.go` + `sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go` | exact |
| `PGSUB/classes/service.go`, `user_subscribable.go` | service | CRUD | `PG/payments/service.go`, `PG/orders/service.go` | role-match |
| `PGSUB/config/config.yaml`, `lang/{en,pl}/lang.yaml` | config | - | `PG/config/config.yaml`, `PG/lang/` | exact |
| `PGSUB/go.mod`, `go.work`, `README.md` | config | - | `PGS/go.mod`, `PGS/go.work` | exact |
| **Plan 3 (PGS)** | | | | |
| `PGS/updates/2026101400xx_add_subscription_webhook_secrets.go` | migration | - | `sm-journal-plugin/updates/202610060007_...go` | exact |
| `PGS/models/settings.go`, `models/settings/fields.yaml` | model/config | - | same files | exact (extend) |
| `PGS/console/import_winter.go` (target flag) | command | batch | same file | exact (extend) |
| `PGS/subscriptions/plugin.go` | provider | - | `PGS/plugin.go` + `PG/plugin.go` Buckets | exact |
| `PGS/subscriptions/webhook.go` | controller | request-response | `PGS/webhook.go` + `PG/routes.go` notify | exact |
| `PGS/subscriptions/job.go` | service (job) | event-driven | `pact.Job`/`conga.Manager.Enqueue` (no plugin job exists yet) | partial |
| `PGS/subscriptions/gateway.go` | service | request-response (outbound) | `PGS/operator.go` + `PGS/client.go` | role-match |
| `PGS/subscriptions/stripefake_test.go` (own fake, D-20) | test | - | `PGS/stripefake_test.go` | exact |
| `PGS/subscriptions/host_test.go` | test | - | `PGS/host_test.go` `bootHost` | exact |
| **Plan 4 (PGSUB admin + API)** | | | | |
| `PGSUB/controllers/subscriptions.go` + `controllers/subscriptions/config_*.yaml` | controller | CRUD (read-only preview) | `PG/controllers/payments.go`, `PG/controllers/payments/config_list.yaml` | exact |
| `PGSUB/controllers/subscriptionplans.go` + `config_{list,form,reorder}.yaml` | controller | CRUD + bulk | `sm-user-plugin controllers/users_admin_controller.go` (bulk), `FW/modules/cabana/testdata/companions/controllers/links/config_reorder.yaml` | role-match |
| `PGSUB/admin.go`, `admin_permissions.go`, `admin_navigation.go` | config | - | `PG/admin.go`, `PG/admin_permissions.go`, `PG/admin_navigation.go` | exact |
| `PGSUB/routes.go`, `PGSUB/api/*.go` | route/controller | request-response | `PG/routes.go` `apiRoutes`, `PG/api/auth.go`, `PG/api/errors.go` | exact |
| **Plan 5 (LAYER)** | | | | |
| `LAYER/app/utils/subscriptionsClient.ts` | utility | request-response | `LAYER/app/utils/client.ts` | exact |
| `LAYER/app/composables/useSubscriptionsApi.ts` | hook | request-response | `LAYER/app/composables/usePaymentGatewayApi.ts`, `useOrders.ts` | exact |
| `LAYER/app/components/Pg/{Plans,MySubscription,SubscribeButton,ManageButton}.vue` | component | request-response | `LAYER/app/components/Pg/OrderList.vue` | role-match |
| `LAYER/nuxt.config.ts`, `i18n/locales/{en,pl}.json`, `tests/subscriptions*.test.ts` | config/test | - | same files, `tests/client.test.ts` | exact |
| **Plan 6** | | | | |
| `FW/scripts/check-phase18.1.sh` | test gate | batch | `FW/scripts/check-phase18.sh` | exact |
## Pattern Assignments
### Plan 1 — `FW/modules/cabana/reorder.go` (flat reorder)
**Analog:** the file itself (525 lines). Extend, do not fork.
- Config doc (lines 32-40): `reorderDocument` with `ParentColumn`, `SortColumn`, `MaxDepth`, `Partition`, decoded with `decodeStrict` (line 105) so unknown keys fail boot. A flat mode needs one new key (e.g. `mode: flat` / `tree`) or "no parentColumn"; defaulting (lines 127-132) currently forces `parent_id`, so flat mode must skip the `parentColumn` default and its column check (line 139 loop).
- Column validation idiom (lines 139-143):
```go
for key, column := range map[string]string{"nameFrom": out.nameFrom, "parentColumn": out.parentColumn, "sortColumn": out.sortColumn} {
if _, ok := cols[column]; !ok || !identifier(column) {
return fail(fmt.Errorf("%s %s is not a column of the record", key, column))
}
}
```
- List schema exposure (lines 67-73, 164-169): `ListReorder{Title, Partition}` → add a flat/mode flag here so the SPA picks the flat UI.
- Save (lines 299-315): `UpdateColumns(map[string]any{parentColumn: parent, sortColumn: node.SortOrder})` — flat mode writes only `sortColumn`.
- `checkReorderNodes` (438-491): flat mode must refuse any non-null `parent_id` (422 on `nodes`) and skip cycle/depth checks; keep the 409 `reorderStale` set-equality check (456-463).
- `reorderTree` (347-412): node `ParentID` stays nil in flat mode (skip line 400-402).
- Error writer (327-340) and route wiring (`reorderGet`/`reorderPost` with `s.protect`, `cc.reorder == nil || cc.singleton` → 404) unchanged.
**Tests:** `FW/modules/cabana/reorder_test.go` — `TestReorderConfigCompile` (line 17, Docker-free) and `TestReorderPostgres` (line 101). Fixture: `FW/modules/cabana/testdata/companions/controllers/links/config_reorder.yaml` (docs embed it via `src=`).
**OpenAPI:** `FW/modules/cabana/admin_openapi.go` lines 486-520 (`AdminReorderGet`, `AdminReorderSave` swag stubs) — update `@Description` for flat mode; then `scripts/check-admin-openapi.sh` regenerates `admin/openapi/admin.json` + `admin/src/api/schema.d.ts`.
### Plan 1 — `FW/modules/cabana/field_taglist.go` (new core `taglist`)
**Analog:** `FW/modules/cabana/field_money.go` (the Phase 18 core field) for key compile/refusal; `field_repeater.go` for an array-valued bound field.
- Type const + keys pattern (field_money.go lines 17-42):
```go
const moneyType = "money"
var moneyKeys = []string{"currency", "currencyFrom", "scale", "max"}
var moneyRefusedKeys = []string{"options", "emptyOption", "nameFrom", "dependsOn", "preset"}
```
- Compile hook (field_money.go lines 54-80) `compileMoneyKeys(typ, values map[string]ast.Node, field *FormField) error` — keys refused on other types; `taglist` gets `mode` (only `array` needed; WinterCMS also has `string` + `separator`), and is called from `form_schema.go` next to line 636 (`compileMoneyKeys`).
- Registration points to touch:
- `form_schema.go` lines 23-30: add `"taglist": {}` to `formFieldTypes`.
- `crud.go` lines 1276-1290: `scalarFormField`/`valueFormField` — taglist must bind to its column (add to `valueFormField` like `repeaterType`).
- `schema_types.go` ~line 321-331: new `FormField` JSON keys (e.g. `Mode`, `Separator`) as money did with `Currency`/`Scale`/`Max`.
- Save-time validation: mirror `checkMoneyValues` call sites (`crud.go:625`, `relation_child.go:103`, `settings.go:202`) — decode JSON array of strings, trim, drop empties, dedupe; 422 on non-array.
- Phrase keys as consts (field_money.go lines 25-31, `backend::lang.money.*`) → `backend::lang.taglist.*`; add to the backend lang bundle.
**Tests:** copy `FW/modules/cabana/money_field_test.go` structure (`TestMoneyFieldCompile` line 22, `TestMoneyFieldValidate` line 224) and `money_field_postgres_test.go` for a jsonb/text round trip.
### Plan 1 — `FW/admin/src/components/form/fields/TaglistField.vue`
**Analog:** `FW/admin/src/components/form/fields/MoneyField.vue` (157 lines).
```ts
import { computed, inject, ref, watch } from 'vue'
import { currentLocale, t } from '../../../app/i18n'
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
import { FIELD_SCOPE } from '../formContext'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
const readOnly = computed(() => { const a = props.field.attributes?.readonly; return !!props.field.readOnly || (a !== undefined && a !== null && a !== false) })
```
Keep the `lastEmitted` guard (MoneyField lines ~55-62) so an external load replaces the draft but own emits do not. Emit `string[]`.
**Registry:** `FW/admin/src/components/form/registry.ts` — import + `renderers` map entry, plus a dated header comment paragraph ("Phase 18 adds the money control (D-07)…" style). A list value is labelled as a group (see repeater note in the header).
**Test:** `FW/admin/tests/form/MoneyField.test.ts` lines 1-30 (vitest + `@vue/test-utils`, `setBundle`, `FIELD_SCOPE` stub, `langFixture`, `resetState`; "No application names appear in framework tests").
### Plan 1 — `FW/admin/src/views/ReorderView.vue` + `components/reorder/tree.ts`
**Analog:** same files. `ReorderView.vue` (445 lines) loads via `api.GET('/{vendor}/{plugin}/{controller}/reorder', …)` (line 92) and saves the whole node set via `api.POST(...)` (line 156) debounced 400 ms; moves are `'up' | 'down' | 'indent' | 'outdent'` (tree.ts line 17) and drop zones `'before' | 'after' | 'inside'` (line 14). Flat mode: hide `indent`/`outdent` and the `inside` zone, send `parent_id: null`. Types come from `admin/src/api/types.ts` lines 75-86 (`AdminReorderTree`, `ListReorder`), regenerated from OpenAPI.
**Test:** `FW/admin/tests/views/ReorderView.test.ts`.
### Plan 1 — docs
- `FW/docs/backend/forms.md`: add a row to the Field types table (line ~106, money row is the template), extend the Field options sentence (line 112), add `## Taglist` after `## Money` (line 463) in the same shape (value shape, keys, refused keys, 422 messages table).
- `FW/docs/backend/lists-and-filters.md` `## Tree reorder` (line 190): add the flat variant and its key in the key table (lines ~205-216). Run `summer docs:sync` if a `src=` fixture is added.
- Gate: `FW/scripts/check-admin-openapi.sh --check`, `FW/scripts/check-admin-dist.sh` (committed `modules/boardwalk/dist`).
### Plan 2 — `PGSUB/plugin.go`
**Analog:** `PG/plugin.go` (144 lines).
- Header doc comment as package doc (lines 1-9), interface assertions block (lines 33-44):
```go
var (
_ party.Plugin = (*Plugin)(nil)
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasLang = (*Plugin)(nil)
_ pact.HasRoutes = (*Plugin)(nil)
_ surf.BucketProvider = (*Plugin)(nil)
)
```
- `//go:embed config` / `//go:embed lang` (lines 52-56); `Requires()` (line 69) → `[]string{"golem15.paymentgateway", "golem15.user"}`; `Register` stores `p.app` (71-74); `Boot` → `p.runBootSteps(app)` (78-81); `Migrations() → updates.All()`, `Models() → models.All()` (87-89); `init() { party.Register(&Plugin{}) }` (142-144).
- Buckets (lines 105-131): copy `APIBucket` keyed by `bouncer.User` id else `surf.ClientIP(r, surf.TrustedProxies(p.app.Config))`.
### Plan 2 — `PGSUB/boot.go` + `contracts/registry.go`
**Analog:** `PG/boot.go` lines 20-53 (`registerBootStep`/`runBootSteps`, errors prefixed `"golem15.paymentgateway: boot %s: %w"`) and lines 96-120 `bootOperators`:
```go
activated, _ := app.Lookup[party.Activated]()
for _, plugin := range activated {
if plugin.ID() == p.ID() { continue }
ho, ok := plugin.(operators.HasOperators)
if !ok { continue }
for _, op := range ho.PaymentOperators() {
if err := reg.Add(plugin.ID(), op); err != nil { return err }
}
}
```
then `app.Publish(reg)`. Registry: `PG/operators/registry.go` lines 10-55 — sentinel errors (`ErrDuplicateCode`, `ErrOperatorNotFound`), `sync.RWMutex`, ordered keys, `Add(pluginID, x)` failing with `"%w: %q is published by %s and %s"`. Reuse for `ErrDuplicateAlias`/`ErrUnknownSubscriberType` (D-08: never skip) and gateways keyed by `Identifier()`.
### Plan 2 — `PGSUB/events/events.go`
**Analog:** `PG/events/events.go` lines 1-62: package doc stating events fire inside the transaction with `Tx`, side effects in `lagoon.AfterCommit`; one `const XEventName = ...` per line (grep-able); shared payload struct embedded per event:
```go
type OrderEvent struct { Order *models.Order; User *usermodels.User; Tx *gorm.DB }
type OrderPlaced struct{ OrderEvent }
```
Names are the literal `golem15.pgsub.*` strings (RESEARCH Pattern 5), not a PHP namespace prefix.
### Plan 2 — `PGSUB/updates/*.go`
**Analog:** `PG/updates/registry.go` (whole file: `Register(ms...)`, `All()`, `run(tx, stmts)`), and per-file `var x = []*gormigrate.Migration{{ID: "<yyyymmddNNNN>_<name>", Migrate: run(...), Rollback: DROP ... IF EXISTS}}` + `init() { Register(x...) }` (`PG/updates/202610130006_create_golem15_paymentgateway_settings.go`). **Difference (D-16):** PG uses plain `CREATE TABLE`; PGSUB must use `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`. Idempotent ALTER template, `sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go`:
```go
return tx.Exec(`ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS golem15_bloghub_author_slug TEXT UNIQUE`).Error
// Rollback: ALTER TABLE ... DROP COLUMN IF EXISTS ...
```
→ `users.stripe_customer_id` (nullable) + `CREATE INDEX IF NOT EXISTS`. Rollback of a shared column: consider a no-op (Horoskopia's column predates the plugin).
### Plan 2 — `PGSUB/models/*`
**Analog:** `PGS/models/settings.go`: `TableName()`, `Fillable()`, `Rules()` (Laravel-style rule strings, e.g. `"required|in:month,year"`), `DropdownOptions(field)` implementing `pact.DropdownOptionsProvider`, `LangPrefix` const, compile-time assertions block (lines 144-147) and `init() { Register(Settings{}) }` with `models/registry.go`. Order/payment richer model shape: `PG/models/payment.go`, labels `PG/models/labels.go`.
### Plan 3 — `PGS/models/settings.go` + migration + fields.yaml (D-18)
Extend in place: add `SubscriptionWebhookSecret`/`TestSubscriptionWebhookSecret lagoon.Encrypted` (`gorm:"column:...;type:text" json:"-"`, cf. lines 50-53), add to `Fillable()` (59-65) and `SecretFallbacks()` (79-86) with new `ConfigSubscriptionWebhookSecret = "golem15.pgstripe.subscription_webhook_secret"` consts (27-32). `fields.yaml`: copy the `webhook_secret` password field block on the live and test tabs. Migration: ALTER ... ADD COLUMN IF NOT EXISTS (journal template above), new file registered via `PGS/updates/registry.go`. `ModeKeys` (`PGS/client.go` 51-65) is the read pattern — add a sibling `SubscriptionWebhookSecret(app, s)` using `fallback(...)` (67-76), rather than changing `ModeKeys`' signature (P18 callers).
### Plan 3 — `PGS/console/import_winter.go` (target flag)
Same file: flags are `bonfire.Flag{Name, Description, Bare}` (lines 97-102) read with `flagValue`/`flagOn` (393-410); PHP→column map `secretColumns` (lines 49-54) and `secretField` switch (line 240-250). Add e.g. `--webhook-target=payments|subscriptions|both`, default `payments` (P18 behaviour unchanged).
### Plan 3 — `PGS/subscriptions/plugin.go` (`golem15.pgstripesubscriptions`)
**Analog:** `PGS/plugin.go` (88 lines) for structure; `PG/plugin.go` Buckets (105-131) for the `throttle:60,1` bucket (Max 60, `surf.ClientIP` key). Requires `golem15.pgstripe` + `golem15.paymentgatewaysubscriptions`; implements `pact.HasRoutes`, `pact.HasJobs` (`Jobs() []pact.Job`, `FW/modules/pact/capabilities.go` 90-103), the pgsub `HasSubscriptionGateways`, `surf.BucketProvider`, `pact.HasConfig` (`metadata_app`, D-19). Own `go.mod`? D-20 says module path `.../sm-pgstripe-plugin/subscriptions` — mirror `PGS/go.mod` + `go.work` replace lines (`replace git.golem15.com/golem15/... => ../...`) and add the PGSUB replace.
### Plan 3 — `PGS/subscriptions/webhook.go`
**Analog:** `PGS/webhook.go` — reuse exported `pgstripe.VerifySignature(payload, header, secret)` (39-47), `pgstripe.ExpandableID` (85-112), `DecodeEvent`-style local minimal decode (114-125, unknown fields ignored); log event id/type only, never body (lines 170-171, 196-216). **Order deviation:** check secret first → 500 before calling `VerifySignature` (it returns `ErrMissingSignature` first, line 40). Route registration copy from `PG/routes.go` lines 34-44:
```go
throttle := surf.Use("throttle:" + WebhookBucket)
r.Post("/webhook/stripe/subscriptions", p.webhook, throttle...)
r.Post("/api/subscriptions/webhook", p.webhook, throttle...)
```
Nil-service guard → `wire.WriteOpaque500(w)` (routes.go 47-53). Enqueue with `conga.Manager.Enqueue(ctx, db, args, conga.EnqueueOpts{})` (`FW/modules/conga/conga.go` 215-240; joins the tx when `db` is in one).
### Plan 3 — `PGS/subscriptions/job.go`
No plugin implements `pact.Job` yet (only `FW/modules/conga` internals). Contract: `pact.JobArgs{ Kind() string }`, `pact.Job{ Work(ctx, args JobArgs) error }` (capabilities.go 90-98); conga registers via `HasJobs` (`FW/modules/conga/worker.go` line 73). Body: RESEARCH Pattern 4 (claim-first `INSERT ... ON CONFLICT (event_id) DO NOTHING` inside one `Transaction`, return error → rollback → retry).
### Plan 3 — `PGS/subscriptions/gateway.go`
**Analog:** `PGS/client.go` — `ClientFactory{App}.New(secret)` (lines 103-120) honours the published `*BackendOverride`; `ModeKeys` picks test/live. Operator method style: `PGS/operator.go`.
### Plan 3 — tests: `PGS/subscriptions/stripefake_test.go`, `host_test.go`
**Analog:** `PGS/stripefake_test.go` lines 1-60 (`httptest` mux with `POST /v1/...` handlers, recorded `fakeRequest`, catch-all 404 `stripeError`) — add `/v1/customers`, `/v1/products`, `/v1/prices`, `/v1/subscriptions/{id}`, `/v1/billing_portal/sessions`. Leave the root fake unchanged (D-20). Host: `PGS/host_test.go` `bootHost` lines 82-160 (freshDB → `lagoon.Use` → `compass.Open` with `SUMMER_*` env → `backpack.New` → `lagoon.Publish` + `PublishEncryptionKeys` → `app.Publish(&pgstripe.BackendOverride{URL: fake.srv.URL, HTTPClient: fake.srv.Client()})` → `party.Activate(app, []string{...})` → `lagoon.Migrate` → `surf.Assemble`); signing via `webhook.GenerateTestSignedPayload` (line 299-301); request helper `notify` (305-319). Fixture plugin pattern: `PGS/fixture_plugin_test.go`; Docker gating: `PGS/testmain_test.go`.
### Plan 4 — `PGSUB/controllers/subscriptions.go` (preview-only)
**Analog:** `PG/controllers/payments.go` lines 1-80: controller struct embedding `base` (`PG/controllers/base.go` 57-200: `application()`, `pool()`, `db(ctx)`, `t()`, `allows()`), `ID()`, `ModelName()` (PHP class string), `ConfigDir()`, `RequiredPermissions()`, `NewRecord()`, `FilterOptions(scope)` returning `[]pact.Option` for the status group filter. `PG/controllers/payments/config_list.yaml`: `recordUrl: .../preview/:id`, no `create` in toolbar, `defaultSort`, `filter: config_filter.yaml`. Controller list registry: `PG/controllers/admin_registry.go`.
### Plan 4 — `PGSUB/controllers/subscriptionplans.go`
- Bulk action: `sm-user-plugin controllers/users_admin_controller.go` lines 548-590 (checkout `sm-summercmsio-app/plugins/golem15/user/`):
```go
func (c usersAdminController) AdminBulkActions() []pact.AdminBulkAction {
return []pact.AdminBulkAction{{
Name: name, Label: ..., Confirm: ..., Permissions: []string{...},
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
tx, ok := cabana.TxFromContext(ctx)
...
return pact.AdminBulkActionResult{Affected: n, Message: msg}, nil
},
}}
}
```
config_list.yaml: `showCheckboxes: true` + `bulkActions: [sync_to_stripe]` (user `controllers/users/config_list.yaml` lines 7, 16). Per RESEARCH Pattern 8 never return an error for a single plan's Stripe failure (rows locked in a tx). Toolbar `sync_all_to_stripe`: `pact.AdminAction` / `HasAdminActions` (`FW/modules/pact/capabilities.go` ~line 270; example `FW/modules/cabana/example_actions_test.go`).
- Reorder: `config_reorder.yaml` modelled on `FW/modules/cabana/testdata/companions/controllers/links/config_reorder.yaml`, using Plan 1's flat mode, `sortColumn: sort_order`; `toolbar.buttons` must list `reorder`.
- `features` field: `type: taglist` + `mode: array` (Plan 1).
### Plan 4 — `PGSUB/admin_permissions.go`, `admin_navigation.go`, `admin.go`
**Analog:** `PG/admin_permissions.go` lines 15-37 (consts + `entry(code, label)` helper with a `permissionTab`); `PG/admin_navigation.go` lines 21-60 (top item `Order: 500` → use 510, `Permissions: []string{"golem15.paymentgatewaysubscriptions.*"}`, `SideMenu` filtered to registered controllers); `PG/admin.go` (`//go:embed` admin FS, `AdminControllers()`).
### Plan 4 — `PGSUB/routes.go` + `PGSUB/api/*`
**Analog:** `PG/routes.go` lines 21-25, 61-87 and `PG/api/auth.go`, `PG/api/errors.go`:
```go
const APIPrefix = "/_paymentgatewaysubscriptions/api/v1"
r.Group(APIPrefix, surf.Use("throttle:"+APIBucket), func(g pact.Router) {
g.Get("/plans", api.Plans(p.app))
g.Get("/subscription", api.RequireJWT(p.app, api.Current(p.app)).ServeHTTP)
g.Post("/subscription/checkout", api.RequireJWT(p.app, api.Checkout(p.app)).ServeHTTP)
...
})
```
`RequireJWT`/`OptionalJWT` (auth.go 40-80: bouncer registry `reg.Middleware("jwt")`, Bearer header only, never cookies). Envelope helpers (errors.go 36-83): `writeAPIError → wire.WriteJSON(w, status, {"error": msg})`, `writeValidation` 422 `{"error","errors"}`, `writeInternal` logs via slog and returns a generic 500. Copy into `PGSUB/api/` (unexported in PG, so duplicate, not import).
### Plan 5 — LAYER
- `app/utils/subscriptionsClient.ts` ← `app/utils/client.ts`: header comment, exported `Pg*` interfaces (money as `PgMoney {amount, amount_minor, currency}` lines 7-12), injected fetcher `createPaymentGatewayClient(fetch)` (lines 162-185), `list<T>()` unwrapping `{data}`, reuse exported `errorStatus`/`fieldErrors`/`isRedirect` (190-219) rather than duplicating.
- `app/composables/useSubscriptionsApi.ts` ← `usePaymentGatewayApi.ts` (whole file, 53 lines): server base from `config.paymentgatewaysubscriptions.apiBase`, public base in browser, `x-forwarded-for` forwarding, reuse `$paymentGatewayAuth` hook.
- `useAsyncData` wrapper ← `useOrders.ts` (key `pg:subscription:*`).
- Components ← `app/components/Pg/OrderList.vue`: header UI-SPEC comment, `useI18n`, error panel with `pg.action.retry`, empty state, `pg-page`/`pg-panel` classes and tokens; redirects via `window.location.href` in buttons only on click (no fetch before mount).
- `nuxt.config.ts` `runtimeConfig` block (lines 52-66): add `paymentgatewaysubscriptions.apiBase` server + public twin.
- Tests ← `tests/client.test.ts` lines 1-50 (`node:test`, stub fetcher recording calls, `loadComposable` from `testing/load.ts`); run by `package.json` `"test": "node --test ... tests/*.test.ts"`.
### Plan 6 — `FW/scripts/check-phase18.1.sh`
**Analog:** `FW/scripts/check-phase18.sh` — stages `go`, `plugins`, `admin`, `layers`, `docs`, `postgres`, `--self-test`, `--all`, required-test-name lists, `PHASE18_REQUIRE_POSTGRES` env; add PGSUB to the plugin repos and `PGS/subscriptions`.
## Shared Patterns
### Plugin boot and registries
**Source:** `PG/boot.go` 20-53, 96-120; `PG/operators/registry.go` 10-55. **Apply to:** PGSUB boot (subscriber + gateway registries), PGS/subscriptions gateway publication.
### Customer API auth + error envelope
**Source:** `PG/api/auth.go` 40-80, `PG/api/errors.go` 36-83, bucket in `PG/plugin.go` 105-131. **Apply to:** all PGSUB `api/*` handlers.
### Encrypted secrets with config fallback
**Source:** `PGS/models/settings.go` 24-32, 50-53, 75-86; `PGS/client.go` 45-76. **Apply to:** subscription webhook secret columns and readers.
### Events fired inside the transaction with `Tx`
**Source:** `PG/events/events.go` 1-62. **Apply to:** six `golem15.pgsub.*` events.
### Idempotent migrations
**Source:** `PG/updates/registry.go`; `sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go`. **Apply to:** PGSUB baseline, `users.stripe_customer_id`, PGS secret columns.
### Stripe test isolation
**Source:** `PGS/client.go` 35-43, 100-120 (`BackendOverride`), `PGS/stripefake_test.go`, `PGS/host_test.go` 82-160. **Apply to:** gateway, webhook, host proof.
### Framework field type end to end
**Source:** money field: `field_money.go`, `form_schema.go:30,636`, `crud.go:1279`, `schema_types.go:321-331`, `MoneyField.vue`, `registry.ts`, `money_field_test.go`, `MoneyField.test.ts`, `docs/backend/forms.md#money`. **Apply to:** `taglist`.
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| `PGS/subscriptions/job.go` | job | event-driven | No plugin implements `pact.Job`/`HasJobs` yet; use the pact contract + `conga.Manager.Enqueue` and RESEARCH Pattern 4. |
| `PGSUB/contracts/gateway.go` (Gateway/Subscriber interfaces) | interface | - | Nearest is `PG/operators/operator.go` (operator interface); shape from RESEARCH Pattern 1. |
## Metadata
**Analog search scope:** `summercms.go/modules/{cabana,pact,conga}`, `summercms.go/admin/{src,tests}`, `summercms.go/docs/backend`, `summercms.go/scripts`, `sm-paymentgateway-plugin/`, `sm-pgstripe-plugin/`, `vue-paymentgateway-layer/`, `sm-journal-plugin/updates`, `sm-summercmsio-app/plugins/golem15/user` (sm-user-plugin).
**Files scanned:** ~45
**Pattern extraction date:** 2026-10-14