docs(18.1): re-verification after gap closure

This commit is contained in:
Jakub Zych
2026-10-16 14:15:52 +02:00
parent f56ebd0828
commit 52d149b8f1
2 changed files with 186 additions and 131 deletions

View File

@@ -3,7 +3,7 @@ status: testing
phase: 18.1-payment-gateway-subscriptions
source: [18.1-VERIFICATION.md]
started: 2026-10-14T19:30:26.848Z
updated: 2026-10-14T19:30:26.848Z
updated: 2026-10-16T12:15:52.076Z
---
## Current Test
@@ -32,12 +32,24 @@ result: [pending]
expected: Taglist chips (Enter, comma, remove, suggestions, read-only) on the plan form; flat drag reorder of plans writes sort_order; bulk sync_to_stripe and toolbar sync_all_to_stripe toasts with per-plan failures; Subscriptions list filters and the preview subscriber partial; the layer's plans table, subscribe redirect, my-subscription cancel through PgConfirm, portal redirect; press Back from Stripe Checkout and check whether the subscribe button is stuck on 'Subscribing...' (review WR-07, unfixed in app/utils/subscribe.ts)
result: [pending]
### 5. Disposition of the re-review warnings (18.1-REVIEW.md, 2026-10-16; 18.1-REVIEW-DISPOSITION.md lists all open) before the Horoskopia cutover
expected: Each of WR-01 to WR-07 is accepted (backlog item or deferred-items entry) or scheduled as a quick task or decimal phase. The money- and listener-relevant ones: WR-02 SubscriptionCanceled fires twice in the deleted-before-created sequence (ordering_test.go never counts canceled); WR-01 Service.Cancel race; WR-03 concurrent plan syncs can create duplicate Stripe products (row lock lost with Detached); WR-04 unpaid open Checkout Sessions not refused; WR-06 checkout bills the last-synced amount, not the shown one; WR-05 gbp/cad/aud/chf plans become unbuyable after a sync; WR-07 incomplete blocks checkout for about 23 h
result: [pending]
### 6. Live Stripe test-mode checkout end to end: test-mode keys and the subscription webhook secret in pgstripe settings, `stripe listen --forward-to <host>/webhook/stripe/subscriptions`, checkout from the layer playground (PgPlans -> PgSubscribeButton) with the user subscriber
expected: Checkout opens on the plan's price in the shown currency; after payment the webhook answers 200 'Webhook received'; the job logs source=stripe (fresh retrieval, not snapshot), stores the subscription active; golem15.pgsub.subscription.activated fires once even when created(incomplete) and updated(active) arrive together; GET /subscription answers has_access true. A second Subscribe answers 409
result: [pending]
### 7. Billing-portal cancellation (D-21 cancel_at question): open the portal through PgManageSubscriptionButton, cancel the subscription, inspect the delivered customer.subscription.updated and the retrieved subscription
expected: If Stripe reports cancel_at_period_end true, the row gets it and PgMySubscription shows the will-cancel notice. If it reports only cancel_at, the row keeps cancel_at_period_end false and the cancellation is invisible; that needs a follow-up mapping
result: [pending]
## Summary
total: 4
total: 7
passed: 0
issues: 0
pending: 4
pending: 7
skipped: 0
blocked: 0

View File

@@ -1,8 +1,8 @@
---
phase: 18.1-payment-gateway-subscriptions
verified: 2026-10-14T19:35:00Z
verified: 2026-10-16T14:45:00Z
status: human_needed
score: 5/5 roadmap success criteria verified (plan must-haves 41/41 verified; 0 present-behavior-unverified)
score: 5/5 roadmap success criteria verified (plan must-haves 64/65 verified, 1 present-behavior-unverified)
covered_files:
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-01-PLAN.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-01-SUMMARY.md
@@ -16,43 +16,73 @@ covered_files:
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-05-SUMMARY.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-06-PLAN.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-06-SUMMARY.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-07-PLAN.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-07-SUMMARY.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-08-PLAN.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-08-SUMMARY.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-09-PLAN.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-09-SUMMARY.md
- admin/src/components/form/fields/TaglistField.vue
- admin/src/views/ReorderView.vue
- modules/cabana/bulk_detached_test.go
- modules/cabana/crud.go
- modules/cabana/field_taglist.go
- modules/cabana/form_schema.go
- modules/cabana/list_schema.go
- modules/cabana/reorder.go
- modules/pact/capabilities.go
- scripts/check-phase10.sh
- scripts/check-phase18.1.sh
covered_digest: "v2:sha256:3f6d8db93c3ed3d1a76a29d5111f67aa4333965ceacec7e0571c0c30035b0e70"
behavior_unverified: 0
covered_digest: "v2:sha256:32f47883441c7cf001b0c0f1e2ca7a9d1d0c7086072759828956104046006979"
behavior_unverified: 1
overrides_applied: 0
re_verification:
previous_status: human_needed
previous_score: "5/5 roadmap SCs, 41/41 plan must-haves (plans 01-06)"
gaps_closed:
- "Human item 1 (decide CR-01 and CR-02): decided by the user on 2026-10-14 (18.1-CONTEXT.md, D-21 amendment) and fixed by gap-closure plans 18.1-07 to 18.1-09"
- "CR-01 stale webhook snapshots: created/updated jobs retrieve Stripe's current state under a per-subscription session advisory lock; deletion before creation retried (ErrSubscriptionNotStored)"
- "CR-02 cross-currency checkout: CheckoutPriceID has no cross-currency fallback; API answers 422, gateway answers ErrNoPriceForCurrency; jpy dropped"
- "WR-01 Stripe calls in the bulk transaction: pact.AdminBulkAction.Detached; sync actions hold no transaction across Stripe calls"
- "WR-02 duplicate Stripe customers: first-write-wins customer id plus idempotency key"
- "WR-03 already-subscribed guard: ScopeOpen, CheckoutGuarded under SubscriberLockKey, checkout idempotency key (partially: see review WR-04/WR-07)"
- "WR-04 archived prices: plan resolution by price, product, plan_code"
- "WR-06 SyncSubscription lost update: locked re-read, gateway-owned columns only"
- "WR-07 buttons stuck after Back: watchPageRestore wired in the three components"
gaps_remaining: []
regressions: []
behavior_unverified_items:
- truth: "WR-07: after a redirect to Stripe Checkout or the billing portal, a back/forward-cache restore re-enables PgSubscribeButton, PgManageSubscriptionButton and PgMySubscription's actions; each component removes its pageshow listener on unmount"
test: "In a real browser, click Subscribe (and Manage subscription, and the portal link in My subscription), let the page leave for Stripe, then press Back"
expected: "The page comes back from the bfcache with the button enabled (not 'Subscribing...'), no error shown; PgMySubscription also refreshes its data"
why_human: "flow.restore() and watchPageRestore are unit-tested, but the component wiring is only asserted as source text (subscriptionComponents.test.ts, review IN-04); no test mounts a component and dispatches pageshow persisted=true, and node --test cannot exercise the real bfcache"
coincidental_reliance_items:
- truth: "SC-4: duplicate-event idempotency"
- truth: "CR-01 sequence 3: an older queued customer.subscription.updated after Service.Cancel fires no SubscriptionReactivated"
reason: incidental-ordering
harden: "TestHostSubscriptionFlow/duplicate_event proves two deliveries of one event fire once, but only for the same event id. Two different events about one subscription (created incomplete and updated active, or deleted before created) are applied in whatever order River runs them (default 4 workers per queue). D-21 accepts PHP's 'apply as they arrive'; if that stops being acceptable, declare an ordering rule (event.created watermark per subscription, or re-retrieve from Stripe in the job). See CR-01."
harden: "TestEventOrdering/stale_cancel_flag_no_reactivated runs the job after Cancel has finished. Nothing orders Cancel against a job that already retrieved Stripe's state: Service.Cancel (classes/service.go:373-395) writes cancel_at_period_end without SubscriptionLockKey, contrary to lock.go's own rule. Take WithSessionLock(SubscriptionLockKey) around the gateway call and the local write (review WR-01)."
human_verification:
- test: "Decide on review findings CR-01 (out-of-order webhook snapshots) and CR-02 (checkout currency fallback to stripe_price_id) before the production cutover"
expected: "Either accept them as PHP parity (D-21 and the frozen getStripePriceId fallback), recorded as overrides or a backlog item, or open a decimal gap-closure phase or quick task: CR-01 by retrieving the subscription from Stripe in the job (or an event.created watermark) and retrying ApplyDeleted on an unknown row; CR-02 by refusing a currency without its own entry in stripe_prices in api/subscription.go and in CreateCheckoutSession"
why_human: "Both are faithful ports of PHP behaviour (verified against pgstripe jobs/ProcessStripeSubscriptionWebhook.php and SubscriptionPlan::getStripePriceId) and D-21 explicitly chose PHP's out-of-order semantics, so they do not fail the 'works as it does on PHP' goal. They are still money-relevant and easier to reach in Go (conga runs 4 workers per queue by default; the new customer API validates currency against local prices only). Accepting or fixing them is a product decision."
- test: "Live Stripe test-mode checkout end to end: test-mode keys and the subscription webhook secret in pgstripe settings, `stripe listen --forward-to <host>/webhook/stripe/subscriptions`, run checkout from the layer playground (PgPlans -> PgSubscribeButton) with the user subscriber"
expected: "Stripe Checkout opens on the plan's price in the shown currency; after payment the webhook answers 200 'Webhook received', the job stores the subscription as active, golem15.pgsub.subscription.activated fires once, GET /subscription answers has_access true; observe whether customer.subscription.created arrives as incomplete followed by updated active (the CR-01 race window)"
why_human: "The Stripe fake cannot prove Stripe's hosted Checkout or real delivery order"
- test: "Billing-portal cancellation (D-21 cancel_at question): open the portal through PgManageSubscriptionButton, cancel the subscription, inspect the delivered customer.subscription.updated"
expected: "If Stripe sends cancel_at_period_end: true, the row gets cancel_at_period_end true and PgMySubscription shows the will-cancel notice. If it sends only cancel_at (newer API versions), the row keeps cancel_at_period_end false and the cancellation is invisible; that would need a follow-up mapping"
- test: "Disposition of the re-review warnings (18.1-REVIEW.md, 2026-10-16; 18.1-REVIEW-DISPOSITION.md lists all open) before the Horoskopia cutover"
expected: "Each of WR-01 to WR-07 is accepted (backlog item or deferred-items entry) or scheduled as a quick task or decimal phase. The money- and listener-relevant ones: WR-02 SubscriptionCanceled fires twice in the deleted-before-created sequence (ordering_test.go never counts canceled); WR-01 Service.Cancel race; WR-03 concurrent plan syncs can create duplicate Stripe products (row lock lost with Detached); WR-04 unpaid open Checkout Sessions not refused; WR-06 checkout bills the last-synced amount, not the shown one; WR-05 gbp/cad/aud/chf plans become unbuyable after a sync; WR-07 incomplete blocks checkout for about 23 h"
why_human: "None defeats a success criterion or a plan must-have as written, and Horoskopia's own flow (pln, synced by plan sync) is unaffected by WR-05 and WR-06's currency side. Whether they block the cutover is a product decision."
- test: "Live Stripe test-mode checkout end to end: test-mode keys and the subscription webhook secret in pgstripe settings, `stripe listen --forward-to <host>/webhook/stripe/subscriptions`, checkout from the layer playground (PgPlans -> PgSubscribeButton) with the user subscriber"
expected: "Checkout opens on the plan's price in the shown currency; after payment the webhook answers 200 'Webhook received'; the job logs source=stripe (fresh retrieval, not snapshot), stores the subscription active; golem15.pgsub.subscription.activated fires once even when created(incomplete) and updated(active) arrive together; GET /subscription answers has_access true. A second Subscribe answers 409"
why_human: "The Stripe fake cannot prove hosted Checkout, real delivery order, or that a real V1Subscriptions.Retrieve body on the pinned API version decodes through decodeSubscription (the new CR-01 path)"
- test: "Billing-portal cancellation (D-21 cancel_at question): open the portal through PgManageSubscriptionButton, cancel the subscription, inspect the delivered customer.subscription.updated and the retrieved subscription"
expected: "If Stripe reports cancel_at_period_end true, the row gets it and PgMySubscription shows the will-cancel notice. If it reports only cancel_at, the row keeps cancel_at_period_end false and the cancellation is invisible; that needs a follow-up mapping"
why_human: "Depends on the Stripe account's API version and portal configuration"
- test: "Browser pass through the admin screens and the layer components"
expected: "Taglist chips (Enter, comma, remove, suggestions, read-only) on the plan form; flat drag reorder of plans writes sort_order; bulk sync_to_stripe and toolbar sync_all_to_stripe toasts with per-plan failures; Subscriptions list filters and the preview subscriber partial; the layer's plans table, subscribe redirect, my-subscription cancel through PgConfirm, portal redirect; press Back from Stripe Checkout and check whether the subscribe button is stuck on 'Subscribing...' (review WR-07, unfixed in app/utils/subscribe.ts)"
expected: "Taglist chips (Enter, comma, remove, suggestions, read-only) on the plan form; flat drag reorder writes sort_order; bulk sync_to_stripe and toolbar sync_all_to_stripe toasts with per-plan failures; Subscriptions list filters and preview subscriber partial; the layer's plans table, subscribe redirect, my-subscription cancel through PgConfirm, portal redirect; Back from Stripe Checkout and the portal re-enables the buttons (WR-07, now fixed: retest)"
why_human: "vitest and SSR specs do not drive a real browser or the back/forward cache"
retest_reason: "UAT row 4 is still pending; the layer components changed in 34cf926 (WR-07 fix), so the Back-button part now checks the fix rather than the defect"
---
# Phase 18.1: Payment Gateway Subscriptions Verification Report
**Phase goal:** The generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS. Horoskopia's subscription flow (plans, checkout, Stripe webhook, `golem15.pgsub.*` events, cancel and portal) works as it does on PHP.
**Mode:** mvp (the ROADMAP goal is not in user-story form; verified against the goal text and its five success criteria)
**Verified:** 2026-10-14T19:35:00Z
**Phase goal:** The generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS. Horoskopia's subscription flow (plans, checkout, Stripe webhook, `golem15.pgsub.*` events, cancel and portal) works as it does on PHP. Event ordering is the exception: the user's 2026-10-14 decision amended D-21 (18.1-CONTEXT.md § Amendments).
**Mode:** mvp. The ROADMAP goal is not in user-story form, so this report verifies against the goal text and its five success criteria, as the first verification did.
**Verified:** 2026-10-16T14:45:00Z
**Status:** human_needed
**Re-verification:** No, initial verification
**Re-verification:** Yes. This run follows gap-closure plans 18.1-07, 08 and 09. The previous run (2026-10-14) ended human_needed with no `gaps:`. Its human item 1 (CR-01/CR-02) was decided as "fix", and this run verifies those fixes in full. Plans 01 to 06 got a regression check through the full gate.
## Goal Achievement
@@ -60,164 +90,177 @@ human_verification:
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | SC-1: Subscription and SubscriptionPlan port on a squashed, idempotent v2.4.0 baseline that adopts existing PHP databases, with polymorphic subscribers registered at build time and a built-in `user` subscribable | VERIFIED | `sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.go`: three tables, every statement `IF NOT EXISTS`, PHP index and constraint names, eight-value status CHECK, RESTRICT FK; dormant tables absent. `202610150002_add_stripe_customer_id_to_users.go` is `ADD COLUMN IF NOT EXISTS` with a no-op rollback. `boot.go` bootRegistries walks `party.Activated` and collects `HasSubscribables` and `HasSubscriptionGateways`, failing on duplicates. `classes/user_subscribable.go` resolves soft-deleted users (Unscoped) and writes only `stripe_customer_id`. TestBaselineSchema, TestRegistry and TestUserSubscribable passed in this run's gate. |
| 2 | SC-2: the `SubscriptionGateway` contract (including `syncPlanToGateway`) has a Stripe implementation; `POST /webhook/stripe/subscriptions` and its legacy alias verify the event, enqueue a job, process it idempotently and fire the six `golem15.pgsub.*` events | VERIFIED | `contracts/gateway.go` declares the 8-method Gateway including SyncPlanToGateway. `sm-pgstripe-plugin/subscriptions/gateway.go` and `sync_plan.go` implement it (usd/eur/pln loop, transfer_lookup_key, archive). `plugin.go` routes both `WebhookPath` and `LegacyWebhookPath` to one handler behind one 60/min bucket. `webhook.go` verifies the signature on the raw body capped at 1 MiB, cheap-checks the marker and enqueues only the five handled types. `job.go` claims the marker first in one transaction (`ClaimEvent`, ON CONFLICT DO NOTHING) and routes to Apply, ApplyDeleted and ApplyInvoice. `events/events.go` holds the six PHP names. The event diff in `classes/apply.go` matches PHP `fireStateChangeEvents` line for line. TestSubscriptionWebhookSmoke, TestSubscriptionJob, TestApplyEvents, TestClaimEventConcurrent, TestSyncPlan and TestSubscriptionGateway are in the gate. |
| 3 | SC-3: SubscriptionPlans and Subscriptions admin screens, a Go service API, a headless customer API and subscription components in `vue-paymentgateway-layer` | VERIFIED | Admin: `controllers/subscriptionplans.go` (sync_to_stripe, sync_all_to_stripe through Service.SyncPlan), `models/subscriptionplan/fields.yaml` (`features: type: taglist, mode: array`), `config_reorder.yaml` (flat), `controllers/subscriptions.go` and the subscriber partial with the unknown-alias danger callout; three permission codes verbatim; navigation order 510. Framework support (`modules/cabana/field_taglist.go`, flat reorder in `reorder.go`) is in tag v0.1.26, including fix `0f09586`. Service API: `classes/service.go` (Latest, Active, Checkout, Portal, Cancel, SyncPlan, SyncSubscription, HasAccess, InGracePeriod and more). Customer API: `routes.go` `/_paymentgatewaysubscriptions/api/v1` with public `/plans` and four `RequireJWT` routes; redirect URLs only from config. Layer: `subscriptionsClient.ts` (five paths), `useSubscriptionsApi.ts` (runtimeConfig server and public bases), PgPlans, PgSubscribeButton (createSubscribeFlow), PgMySubscription, PgManageSubscriptionButton; package version 0.2.0, tag v0.2.0 on origin. Gate: admin smokes, TestCustomerAPISmoke and TestCustomerAPIAuthz passed; the layer reported 36 `subscriptions:` TAP tests passing, 105/105 total. |
| 4 | SC-4: a host test proves checkout, activation, `invoice.paid`, `past_due` with its grace period, cancel and duplicate-event idempotency against a Stripe fake | VERIFIED | `sm-pgstripe-plugin/subscriptions/host_test.go` TestHostSubscriptionFlow has subtests checkout_activation, invoice_paid_basil, invoice_paid_pre_basil, past_due_grace (access inside the grace period, none after it), cancel_at_period_end, cancel_immediately, reactivation, duplicate_event (two deliveries, past_due fires once, one marker, third delivery answers 'Already processed'), app_tag, plan_sync, portal, sync_subscription and real_worker. All six event names are asserted. The run is in the gate's postgres stage. |
| 5 | SC-5: the new code has unit tests, delivered in the phase's last plan | VERIFIED | Plan 06 commits: summercms.go `323b6d6` and `3cc4a5f` (taglist and flat reorder matrices, gate with self-test); pgsub `1da437f` (13 files, 2,469 lines: models, registry, apply events, baseline, service API, admin actions, customer API authz, lang parity); pgstripe `88f9125` (decode, job, sync_plan, gateway, php_map, subscription secrets); layer `30046f5`. `scripts/check-phase18.1.sh --self-test` passed in this run. |
| 1 | SC-1: squashed idempotent baseline, build-time polymorphic subscribers, built-in `user` subscribable | VERIFIED | Unchanged since the first verification. TestBaselineSchema and TestUserSubscribable passed in this run's postgres stage. `user_subscribable.go` SetCustomerID now writes only when the column is empty (`UPDATE users SET stripe_customer_id = ? WHERE id = ? AND (stripe_customer_id IS NULL OR stripe_customer_id = '')`). It still writes only `stripe_customer_id`. |
| 2 | SC-2: Stripe gateway incl. syncPlanToGateway; both webhook routes verify, enqueue, process idempotently, fire six events | VERIFIED | `subscriptions/job.go` `work` sends created/updated to `applySubscription`. That path answers from the marker first (`EventProcessed`), routes a foreign app to claim-and-skip with no Stripe call, and otherwise takes `classes.WithSessionLock(SubscriptionLockKey)`. Under the lock it calls `RetrieveSubscription` outside any transaction (the 404 path falls back to the snapshot) and then claims the marker and calls `Apply` in a single `lagoon.Transaction`. Deletions run under the same lock. TestEventOrdering, TestFreshStateSmoke, TestSubscriptionJob, TestSubscriptionWebhookSmoke, TestRetrieveSubscription and TestHostSubscriptionFlow passed. |
| 3 | SC-3: admin screens, Go service API, headless customer API, layer components | VERIFIED | The admin `sync_to_stripe` declares `Detached: true` (`controllers/subscriptionplans.go:55`). cabana's `detachedBulkAction` (`crud.go:412-435`) loads the selected plans through `loadScoped(..., false)` with no transaction. `SyncPlan(ctx, nil, plan)` saves the ids with `context.WithoutCancel`. In the service API, `CheckoutGuarded`, `Open` and a locked `SyncSubscription` are new. In the customer API, the 409 uses `Open` (`api/subscription.go:92-98`) and the 422 uses `CheckoutPriceID` (`:128-131`), both before any gateway call. In the layer, `watchPageRestore` is wired in SubscribeButton, ManageSubscriptionButton and MySubscription, and package.json is 0.2.1. TestAdminSyncDetached, TestCustomerAPIGuards, TestCustomerAPISmoke and TestCustomerAPIAuthz passed, and the layer ran 112/112 TAP (43 `subscriptions:`). |
| 4 | SC-4: host test proves checkout, activation, invoice.paid, past_due grace, cancel, duplicate idempotency against a fake | VERIFIED | TestHostSubscriptionFlow passed in this run, now against the fake's retrieve endpoint. TestEventOrdering adds the checkout race in both orders and concurrently, deleted-before-created, the stale cancel flag, a retrieval failure retried with no marker, and duplicate or foreign events making no Stripe call. |
| 5 | SC-5: unit tests delivered in the phase's last plan | VERIFIED | Plan 09 is the last plan and adds the named tests for every plan 07 and 08 behaviour: cabana `bulk_detached_test.go`; pgsub `checkout_price_test.go`, `plan_resolution_test.go`, `sync_subscription_test.go`, `checkout_guard_test.go` and `admin_sync_detached_test.go`; pgstripe `ordering_test.go` and `gateway_hardening_test.go`; the layer's `subscribe.test.ts` and `subscriptionComponents.test.ts`. The gate requires each of the 13 new Go names by stage and package (grep count 1 each in `scripts/check-phase18.1.sh`). |
**Score:** 5/5 roadmap truths verified (0 present, behavior-unverified). All 41 plan must-have truths across plans 01 to 06 trace to the code above or to the D-ID table below.
**Score:** 5/5 roadmap truths verified (0 present, behavior-unverified at SC level).
### Plan must-haves of the gap closure (plans 07 to 09)
| Plan | Truth | Status | Evidence |
|------|-------|--------|----------|
| 07 | CR-01 seq 1: fresh state outside any tx under SubscriptionLockKey; stale event cannot regress an active row | VERIFIED | `job.go applySubscription`; TestFreshStateSmoke/stale_updated_after_active, TestEventOrdering/checkout_race_* |
| 07 | CR-01 seq 2: ApplyDeleted on an unstored id wraps ErrSubscriptionNotStored, marker rolled back; late created stores canceled, no activated | VERIFIED | `apply.go` ApplyDeleted `len(rows)==0` branch; TestApplyDeletedNotStored, TestEventOrdering/deleted_before_created (asserts canceled row, 0 activated, 0 then 1 marker) |
| 07 | CR-01 seq 3: older queued updated after Service.Cancel fires no reactivated | VERIFIED (coincidental-reliance) | TestEventOrdering/stale_cancel_flag_no_reactivated. It holds when the job runs after Cancel; the race with a job already past its retrieve is open (review WR-01), see `coincidental_reliance_items` |
| 07 | Claim-first kept; duplicates and foreign apps make no Stripe call; failed retrieve or Apply leaves no marker; 404 falls back to the snapshot with Warn | VERIFIED | `applySubscription` steps 1-3; TestEventOrdering/duplicate_makes_no_stripe_call, other_app_makes_no_stripe_call, retrieve_error_retries; `RetrieveSubscription` maps 404/resource_missing to ErrSubscriptionMissing |
| 07 | WR-04: plan resolution by price, stripe_prices, product, plan_code (inactive incl.); Update/SubscriptionArgs carry product_id/plan_code; job args PII-free | VERIFIED | `apply.go` resolvePlan; TestPlanResolution; TestJobArgsMinimal passed (go stage) |
| 07 | WR-06: SyncSubscription retrieves outside tx, locked re-read, gateway-owned columns only, plan from price on retriever path, same lock, no events | VERIFIED | `service.go` SyncSubscription/gatewayFields (code read above); TestSyncSubscriptionFresh incl. the held-lock wait |
| 07 | D-21 amended for ordering in READMEs and CONTEXT | VERIFIED | pgsub README:275, pgstripe subscriptions README:141, CONTEXT § Amendments line 68 |
| 07 | Compatibility: no route/event/table/column/config/response change; Gateway keeps 8 methods (retriever optional); D-06; go vet/test green | VERIFIED | `contracts.SubscriptionRetriever` is a separate interface; `go list -deps ./...` of pgsub names 0 stripe-go packages (run here); `go test ./...` green in both plugin repos (run here, with Docker) |
| 08 | CR-02 API: 422 without a gateway call when CheckoutPriceID is empty; synced map decides alone; unsynced stripe_price_id only in the own currency | VERIFIED | `subscription_plan.go:129-144`; `api/subscription.go:128-131`; TestCheckoutPriceID, TestCustomerAPIGuards |
| 08 | CR-02 gateway: CreateCheckoutSession uses CheckoutPriceID, ErrNoPriceForCurrency before any Stripe call; jpy out; StripePriceIDFor keeps PHP fallback | VERIFIED | `gateway.go:199-202`, `:44`; TestGatewayStrictPrice |
| 08 | WR-02: customer idempotency key; first-write-wins stored id; WithoutCancel store | VERIFIED | `gateway.go:164`; `user_subscribable.go` SetCustomerID; `service.go` ensureCustomer; TestCustomerIDFirstWriteWins, TestGatewayIdempotencyKeys |
| 08 | WR-03: 409 for any non-terminal status; CheckoutGuarded under SubscriberLockKey; session idempotency key per subscriber/plan/currency/params in a 10-min window | VERIFIED | `models/subscription.go` ScopeOpen/TerminalStatuses; `service.go:319-344`; `gateway.go:246`; TestCheckoutGuarded. Residuals in review WR-04 (sessions that are open but unpaid) and WR-07 (incomplete lockout) |
| 08 | WR-01: Detached bulk action without tx or row locks; ids saved right after calls, cancellation-proof; sync_all opens no tx; failed save is a failure line | VERIFIED | `pact/capabilities.go:292-300`, `cabana/crud.go:370-373, 412-435`, `subscriptionplans.go` syncOne/runSyncAll; TestDetachedBulkAction (NOWAIT probe), TestAdminSyncDetached (idle-in-transaction probe). Residual: lost per-plan serialisation (review WR-03) |
| 08 | WR-07: bfcache restore re-enables the three components; throwing navigation re-enables with generic error; listener removed on unmount | PRESENT_BEHAVIOR_UNVERIFIED | `subscribe.ts` watchPageRestore/navigate/resetState are unit-tested. The component wiring (onMounted/onBeforeUnmount in all three `.vue` files) is present but only source-asserted (review IN-04). Routed to human item 4 |
| 08 | Compatibility: shapes unchanged, Service.Checkout unchanged, non-detached bulk actions unchanged, D-06, tests green | VERIFIED | Non-detached branch of `BulkAction` is the prior code path; TestDetachedBulkAction asserts the non-detached case unchanged; framework `go vet ./...` and `go test -short ./...` green (run here) |
| 09 | SC-5 named tests in four repos; go vet/test green; layer # fail 0 | VERIFIED | Gate output: go, plugins, postgres and layers stages PASS; `# tests 112 # pass 112 # fail 0` |
| 09 | CR-01 proof (TestEventOrdering) | VERIFIED | 7 subtests listed in `ordering_test.go`; passed in postgres stage |
| 09 | WR-04/WR-06 proof | VERIFIED | TestPlanResolution, TestApplyDeletedNotStored, TestSyncSubscriptionFresh passed |
| 09 | CR-02 proof | VERIFIED | TestCheckoutPriceID (go stage), TestGatewayStrictPrice, TestCustomerAPIGuards passed |
| 09 | WR-02/WR-03 proof | VERIFIED | TestCustomerIDFirstWriteWins, TestCheckoutGuarded, TestGatewayIdempotencyKeys, TestCustomerAPIGuards passed |
| 09 | WR-01 proof | VERIFIED | TestDetachedBulkAction, TestAdminSyncDetached passed |
| 09 | WR-07 proof (layer specs) | VERIFIED | 43 `subscriptions:` TAP tests (minimum raised to 43). The specs cover the flow; the component wiring is text-only, which is the truth row above |
| 09 | Gate requires every new name; self-test; PHASE181_REQUIRE_POSTGRES=1 --all green; VALIDATION rows for 07-09, validated, nyquist_compliant | VERIFIED | Run here: EXIT=0, "Phase 18.1 gate passed", 0 skip lines; VALIDATION.md `status: validated`, `nyquist_compliant: true`, 17 references to 18.1-07/08/09 |
| 09 | D-22 release in the stated order after the human's reply | VERIFIED | The human's reply was "push-all-with-plugin-tags, v0.2.1", so the tag is v0.2.1 instead of the plan's v0.1.27, which already existed on origin. `v0.2.1` → `f64ae79` and contains `fc2a397` (Detached). pgsub `v0.1.0` → `41b68f6` (requires summercms v0.2.1), pgstripe `v0.1.0` → `2a427fd` (requires summercms v0.2.1 and pgsub v0.1.0), layer `v0.2.1` → `22368c5` (package.json 0.2.1). All tags are on origin (`git ls-remote`); all three sibling repos are `main...origin/main` clean |
Plans 01 to 06 (41 truths) got a regression check. Every test the first verification relied on passed again in this run's gate. No file the first run verified lost its wiring, and the only changes to those files are the plan 07 and 08 edits verified above.
**Plan must-have score:** 64/65 verified, 1 present but behavior-unverified (WR-07 component wiring).
### Requirements Coverage (phase-local IDs; REQUIREMENTS.md maps no IDs to 18.1)
| ID | Plans | Status | Evidence |
|----|-------|--------|----------|
| SC-1 | 02, 06 | SATISFIED | Truth 1 |
| SC-2 | 02, 03, 06 | SATISFIED | Truth 2 |
| SC-3 | 01, 02, 04, 05, 06 | SATISFIED | Truth 3 |
| SC-4 | 03, 06 | SATISFIED | Truth 4 |
| SC-5 | 06 | SATISFIED | Truth 5 |
| D-01 | 02, 03, 06 | SATISFIED | Frozen status, gateway, table and event strings (`events/events.go`, `models.Statuses`, baseline CHECK); lead fees, metering, placements and analytics not ported (no such files) |
| D-02 | 02, 04 | SATISFIED | Three permission codes verbatim incl. `manage_placements` with no screen (`admin_permissions.go`) |
| D-03 | 02, 06 | SATISFIED | `models/feature_list.go` list plus legacy map decode; TestFeatureList |
| D-04 | 02, 06 | SATISFIED | Idempotent baseline; TestBaselineSchema golden names |
| D-05 | 02, 06 | SATISFIED | `GatewayStripe`, `GatewayGooglePlay`; `ErrGatewayNotInstalled` (`contracts/registry.go`) |
| D-06 | 02 | SATISFIED | `go list -deps ./...` of pgsub names 0 stripe-go packages (run here) |
| D-07 | 03 | SATISFIED | `golem15.pgstripesubscriptions` is its own plugin; `go list -deps .` of the pgstripe root names 0 pgsub packages (run here) |
| D-08 | 02, 04, 06 | SATISFIED | Build-time registries in `boot.go`; unknown alias returns ErrUnknownSubscriberType; the admin partial shows a danger notice |
| D-09 | 02, 06 | SATISFIED | users column migration plus a targeted UPDATE only (`user_subscribable.go:89`) |
| D-10 | 03 | SATISFIED | Two routes, one handler, one 60/min IP bucket (`subscriptions/plugin.go`) |
| D-11 | 03, 06 | SATISFIED | Webhook never writes the marker; the job claims first; TestSubscriptionJob STRIPE-006/007 |
| D-12 | 03, 06 | SATISFIED | Five plain-text answers in PHP order (`webhook.go`); job args are PII-free (`WebhookJobArgs`); TestJobArgsMinimal |
| D-13 | 02, 04, 05, 06 | SATISFIED | Service API and customer API; TestServiceAPI, TestCustomerAPIAuthz |
| D-14 | 05 | SATISFIED | Layer client, composables, four Pg components, en/pl copy, no new dependency (`git diff v0.1.0 v0.2.0 -- package.json` changes only the version; lockfile unchanged) |
| D-15 | 03, 06 | SATISFIED | `sync_plan.go` port of d00f850; TestSyncPlan |
| D-16 | 02, 06 | SATISFIED | Dormant tables not created; JSONB baseline confirmed at the 02 T1 checkpoint |
| D-17 | 01, 04, 06 | SATISFIED | Core taglist and flat reorder in cabana; the plan form uses them; framework tests in the gate's go and postgres stages |
| D-18 | 03, 06 | SATISFIED | Additive secret columns and `SubscriptionWebhookSecret` (`client.go` diff is +19 lines, additive only); `--webhook-secret-target`; TestStripeSubscriptionSecrets |
| D-19 | 03, 06 | SATISFIED | `metadata_app` config key, empty by default (`gateway.go:148`, `job.go` filter); no billing_owner_id |
| D-20 | 03 | SATISFIED | Subpackage plugin registered in init() |
| D-21 | 03, 04, 05, 06 | SATISFIED (manual item open) | PHP cancel mapping kept, out-of-order applied as they arrive, both payload shapes decoded (TestDecodeSubscription, TestDecodeInvoice); GET /subscription returns the latest with has_access and in_grace_period. The portal `cancel_at` question is a human item. |
| D-22 | 01, 02, 04, 05, 06 | SATISFIED | All four repos pushed (`main...origin/main` clean for pgsub, pgstripe and the layer); tags v0.1.26 and v0.2.0 on origin; plugin go.mod files require v0.1.26 (`472b75b`, `baf3c55`). summercms.go is one commit ahead of origin: the planning-only review report `929cfd8`. |
| SC-1 to SC-5 | 01-09 | SATISFIED | Truths 1-5 |
| D-01 to D-20, D-22 | 01-09 | SATISFIED | As in the first verification. D-06 re-run: 0 stripe-go deps. D-22: release record above |
| D-21 | 03-07 | SATISFIED (amended) | Ordering amended by the user's decision. The cancel_at_period_end mapping, both payload shapes and the latest subscription with flags are unchanged. The portal `cancel_at` question remains a human item |
| CR-01, CR-02 | 07, 08, 09 | SATISFIED | Gap-closure truths above |
| WR-01, WR-02, WR-03, WR-04, WR-06, WR-07 | 07, 08, 09 | SATISFIED (WR-07 behaviour pending browser check) | Gap-closure truths above |
| WR-05 (first review) | none | DEFERRED by user scope | Grace period from the advanced period end, kept PHP-identical (`deferred-items.md`) |
No orphaned requirements: REQUIREMENTS.md has no rows for Phase 18.1.
No orphaned requirements.
### Required Artifacts
### Required Artifacts (gap closure)
| Artifact | Status | Details |
|----------|--------|---------|
| `summercms.go/modules/cabana/field_taglist.go` | VERIFIED | compileTaglistKeys and checkTaglistValues wired from form_schema.go and crud.go; tests in the gate |
| `summercms.go/modules/cabana/reorder.go` | VERIFIED | flat inference and save; `list_schema.go` keeps Flat after fix 0f09586 (in v0.1.26) |
| `summercms.go/admin/src/components/form/fields/TaglistField.vue` | VERIFIED | registered in registry.ts; vitest 1401 passed |
| `sm-paymentgatewaysubscriptions-plugin/{plugin,boot}.go`, `contracts/*`, `classes/*`, `events/events.go`, `updates/*` | VERIFIED | substantive, wired through Boot and published services |
| `sm-paymentgatewaysubscriptions-plugin/controllers/*`, `routes.go`, `api/*` | VERIFIED | admin and customer API wired; smokes in the gate |
| `sm-pgstripe-plugin/subscriptions/{plugin,gateway,sync_plan,webhook,decode,job}.go` | VERIFIED | gateway published via SubscriptionGateways(); job registered via Jobs() |
| `sm-pgstripe-plugin/updates/202610150001_add_subscription_webhook_secrets.go` | VERIFIED | ADD COLUMN IF NOT EXISTS |
| `vue-paymentgateway-layer/app/{utils,composables,components/Pg}/*` (subscriptions) | VERIFIED | no v-html in the new components (the only v-html is the pre-existing BankTransfer.vue) |
| `summercms.go/scripts/check-phase18.1.sh` | VERIFIED | PHASE181_REQUIRE_POSTGRES, self-test, required names per repo |
| `sm-paymentgatewaysubscriptions-plugin/classes/lock.go` | VERIFIED | `pg_advisory_lock(hashtextextended(?,0))` on a pinned connection; WithoutCancel unlock; discards the connection on a lock or unlock failure |
| `sm-pgstripe-plugin/subscriptions/gateway.go` | VERIFIED | RetrieveSubscription, ErrSubscriptionMissing, ErrNoPriceForCurrency, SetIdempotencyKey on customer and checkout creates, Gateway.Now |
| `sm-pgstripe-plugin/subscriptions/job.go` | VERIFIED | applySubscription under SubscriptionLockKey; deletion under the same lock |
| `sm-paymentgatewaysubscriptions-plugin/classes/apply.go` | VERIFIED | ErrSubscriptionNotStored; price, product and plan_code resolution |
| `sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go` | VERIFIED | `func (p SubscriptionPlan) CheckoutPriceID` |
| `sm-paymentgatewaysubscriptions-plugin/classes/service.go` | VERIFIED | CheckoutGuarded, Open, locked SyncSubscription, detached SyncPlan save |
| `modules/pact/capabilities.go`, `modules/cabana/crud.go` | VERIFIED | `Detached bool json:"-"`; detachedBulkAction branch |
| `vue-paymentgateway-layer/app/utils/subscribe.ts` | VERIFIED | `pageshow` with persisted; restore on both flows |
| `sm-pgstripe-plugin/subscriptions/ordering_test.go`, `fresh_state_smoke_test.go`, `gateway_hardening_test.go` | VERIFIED | TestEventOrdering, TestFreshStateSmoke, TestGatewayStrictPrice, TestGatewayIdempotencyKeys |
| `sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go` and siblings | VERIFIED | TestCheckoutGuarded etc. |
| `modules/cabana/bulk_detached_test.go` | VERIFIED | TestDetachedBulkAction |
| `scripts/check-phase18.1.sh` | VERIFIED | requires every new name; ran green |
| `18.1-VALIDATION.md` | VERIFIED | validated, nyquist_compliant, rows for 07-09 |
### Key Link Verification
### Key Link Verification (gap closure)
| From | To | Via | Status |
|------|----|-----|--------|
| pgsub `boot.go` | `contracts/registry.go` | HasSubscriptionGateways and HasSubscribables collected from party.Activated | WIRED |
| pgstripe `subscriptions/plugin.go` | pgsub registry | `SubscriptionGateways()` returns `NewGateway(p.app)` | WIRED |
| `subscriptions/webhook.go` | `client.go` | `pgstripe.SubscriptionWebhookSecret` then `VerifySignature` | WIRED |
| `subscriptions/webhook.go` | `subscriptions/job.go` | `conga.From(app).Enqueue(WebhookJobArgs)` | WIRED |
| `subscriptions/job.go` | `classes/apply.go` | ClaimEvent, Apply, ApplyDeleted, ApplyInvoice in one lagoon.Transaction | WIRED |
| `classes/apply.go` | `events/events.go` | `s.bus.Fire` inside tx | WIRED |
| `controllers/subscriptionplans.go` | `classes/service.go` | `svc.SyncPlan` | WIRED |
| `routes.go` | `api/subscription.go` | `pgapi.RequireJWT(...)` | WIRED |
| `models/subscriptionplan/fields.yaml` | cabana taglist | `type: taglist`, `mode: array` | WIRED |
| layer `SubscribeButton.vue` | `utils/subscribe.ts` | `createSubscribeFlow` | WIRED |
| layer `subscriptionsClient.ts` | pgsub `routes.go` | `/plans`, `/subscription`, `/subscription/checkout`, `/portal`, `/cancel` | WIRED |
| `scripts/check-phase18.1.sh` | host_test, baseline_test, layer tests | required names TestHostSubscriptionFlow, TestBaselineSchema, `subscriptions.test.ts` | WIRED |
| `subscriptions/job.go` | `subscriptions/gateway.go` | `NewGateway(p.app).RetrieveSubscription` inside the lock, before `lagoon.Transaction` | WIRED |
| `subscriptions/job.go` | `classes/lock.go` | `classes.WithSessionLock(ctx, db, classes.SubscriptionLockKey(id), ...)` (created/updated and deleted) | WIRED |
| `classes/service.go` SyncSubscription | `contracts/gateway.go` | `gw.(contracts.SubscriptionRetriever)` | WIRED |
| `api/subscription.go` Checkout | `classes/service.go` | `d.svc.CheckoutGuarded`, `errors.Is(err, classes.ErrAlreadySubscribed)` → 409 | WIRED |
| `subscriptions/gateway.go` CreateCheckoutSession | `models/subscription_plan.go` | `plan.CheckoutPriceID(currency)` | WIRED |
| `controllers/subscriptionplans.go` | `modules/cabana/crud.go` | `Detached: true` → `detachedBulkAction` | WIRED |
| layer `SubscribeButton.vue`, `ManageSubscriptionButton.vue`, `MySubscription.vue` | `utils/subscribe.ts` | `onMounted` `watchPageRestore(window, ...)`, `onBeforeUnmount(stopRestore)` | WIRED (behaviour unexercised at component level) |
| `scripts/check-phase18.1.sh` | `ordering_test.go`, `bulk_detached_test.go` | required names in the postgres stage | WIRED |
| plugin `go.mod` files | summercms tag | `git.golem15.com/golem15/summercms v0.2.1` (the plan's pattern said v0.1.27; the human chose v0.2.1) | WIRED |
### Data-Flow Trace (Level 4)
| Artifact | Data | Source | Real data | Status |
|----------|------|--------|-----------|--------|
| GET /plans (`api/plans.go`) | active plans | `Service.ActivePlans` GORM query, is_active ordered by sort_order | yes | FLOWING |
| GET /subscription | latest subscription and flags | `Service.Latest` query plus HasAccess and InGracePeriod | yes | FLOWING |
| Webhook to row | subscription state | signed event to job args to `Apply` upsert | yes | FLOWING |
| PgPlans and PgMySubscription | plans, subscription | usePlans and useSubscription through the client over useSubscriptionsApi | yes | FLOWING |
| Webhook job → row | subscription state | `V1Subscriptions.Retrieve` → `decodeSubscription` → `Apply` (the snapshot only on a 404) | yes | FLOWING |
| POST /subscription/checkout | Stripe price id | `stripe_prices[currency]`, or `stripe_price_id` in the plan's own currency → session create | yes. The amount is the one fixed at the last sync, not `currency_prices` (review WR-06) | FLOWING |
| GET /plans, GET /subscription | plans, latest subscription | GORM queries (unchanged) | yes | FLOWING |
### Behavioral Spot-Checks and Probe Execution
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| pgsub links no Stripe SDK (D-06) | `go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./... \| grep -c stripe-go` | 0 | PASS |
| pgstripe root links no pgsub (D-07) | `go -C ../sm-pgstripe-plugin list -deps . \| grep -c paymentgatewaysubscriptions` | 0 | PASS |
| Full phase gate, Postgres required | `PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all` | exit 0; PASS self-test, go, plugins, admin (vitest 1401), layers (105/105 TAP, 36 subscriptions), docs, postgres (cabana 3, pgsub 14, pgstripe root 4, subscriptions 7 incl. TestHostSubscriptionFlow); "Phase 18.1 gate passed" | PASS |
| Tags published | `git ls-remote --tags origin v0.1.26` / layer `v0.2.0` | both present; fix 0f09586 is an ancestor of v0.1.26 | PASS |
| Layer release additive | `git -C ../vue-paymentgateway-layer diff v0.1.0 v0.2.0 --stat -- package.json pnpm-lock.yaml` | package.json version line only | PASS |
| Full phase gate, Postgres required | `PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all` | EXIT=0. PASS: self-test, go, plugins (vet both plugins), admin (vitest 1429), layers (112/112, 43 subscriptions), docs (docs:build clean, admin dist matches), postgres (cabana 4, pgsub 21, pgstripe root 4, subscriptions 12). "Phase 18.1 gate passed"; 0 skip lines | PASS |
| Plugin suites with Docker | `go test ./...` in pgsub and pgstripe | all ok | PASS |
| Framework | `go vet ./...`; `go test -short ./...` | no output except ok lines | PASS |
| D-06 | `go list -deps ./...` in pgsub, grep stripe-go | 0 | PASS |
| Release tags | `git ls-remote --tags origin` in four repos; `merge-base --is-ancestor fc2a397 v0.2.1` | v0.2.1, v0.1.0, v0.1.0, v0.2.1 present; Detached contained | PASS |
The orchestrator also reports `scripts/check-phase18.sh --all` and `go test ./...` (40 packages) green in this run, so Phase 18 did not regress.
### Code Review Findings Weighed (18.1-REVIEW.md, re-review 2026-10-16: 0 critical, 7 warnings, 5 info)
### Code Review Findings Weighed (18.1-REVIEW.md)
The review confirms that both blockers from the first review are closed. I read the code paths it cites. Each warning below is a residual of, or a side effect of, a fix. None makes a roadmap SC or a plan must-have false as worded.
**CR-01 (stale webhook snapshots).** I compared `job.go` and `classes/apply.go` with PHP `pgstripe/jobs/ProcessStripeSubscriptionWebhook.php` at the port source. PHP also:
- applies `data.object` as a snapshot with no `event.created` check (`firstOrNew` with no lock);
- answers a deleted-before-created with a logged return and a recorded marker;
- fails `subscription_plan_id` validation on an unknown price, which throws and retries.
- **WR-01 (Cancel without the subscription lock).** Confirmed: `Service.Cancel` (`service.go:373-395`) writes the flag through `s.db` with no lock. This is a narrow race in the CR-01 sequence 3 truth, recorded under `coincidental_reliance_items`. The sequential case is tested and holds.
- **WR-02 (SubscriptionCanceled twice).** Confirmed by reading:
- the late created event stores `canceled` from no previous status, so `fireStateChangeEvents` fires canceled;
- the retried `ApplyDeleted` then fires unconditionally.
The Go code reproduces each of these, adds a `FOR UPDATE` lock on the existing row and makes the claim-first marker transactional. Those are strict improvements. D-21 locks "out-of-order events behave as PHP does". The goal "works as it does on PHP" is therefore met, and CR-01 is not a blocker against it.
`deleted_before_created` asserts 0 activated but never counts canceled. PHP never reached this state. On PHP the late created would have stored the snapshot (active), so Go is strictly safer on access, but a listener runs twice. WARNING.
- **WR-03 (plan sync serialisation lost).** Confirmed: the detached path loads with `loadScoped(..., false)`, and `SyncPlan` takes no lock. WARNING.
- **WR-04 (unpaid sessions) and WR-07 (incomplete lockout).** Both follow from the plan 08 guard design that the must-have spells out ("incomplete" is listed as open). They are product trade-offs. WARNING.
- **WR-05 (non-sync currencies unbuyable after a sync) and WR-06 (amount drift).** Both are in the new customer API's money path. Horoskopia's checkout is `pln`, a sync currency. WARNING.
- **IN-01 to IN-05.** Informational only.
The race is still real. conga runs 4 workers per queue by default (`modules/conga/client.go:23`), so concurrent created and updated jobs for one subscription are plausible on every Checkout. It is recorded as a human decision item and in `coincidental_reliance_items`.
**CR-02 (currency fallback).** The PHP `SubscriptionPlan::getStripePriceId` falls back to `stripe_price_id` exactly as Go `StripePriceIDFor` does. The gateway's usd/eur/pln sync is the frozen d00f850 code. Horoskopia's own checkout (`plugins/golem15/horoscope/routes.php:641`) always passes `pln`, which plan sync prices, so Horoskopia's flow is unaffected.
The new customer API widens the exposure. Its `priced()` check validates local prices only, so a gbp, cad, aud, chf or jpy plan, or a `currency_prices` entry that was never synced, passes the 422 gate and is billed in USD. This is a WARNING for any other consumer and a human decision item.
**Warnings WR-01 to WR-07.** None defeats a success criterion:
- WR-05 (grace from the advanced period end) is identical to PHP `isInGracePeriod`.
- WR-02, WR-03 and WR-07 are in new Go and layer code, not PHP parity. WR-07 is confirmed unfixed: `subscribe.ts` sets `redirecting` and never resets it.
- They are listed as anti-pattern warnings below for a follow-up.
They are collected as human item 1 for a disposition before the cutover.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| sm-pgstripe-plugin/subscriptions/job.go | 119-147 | event snapshot applied with no ordering guard (CR-01, PHP parity per D-21) | WARNING | older event can overwrite newer state; human decision |
| sm-paymentgatewaysubscriptions-plugin/api/subscription.go | 112-119, 140-149 | local-price currency check, gateway falls back to stripe_price_id (CR-02, fallback inherited) | WARNING | non-synced currency billed at the USD price via the new API |
| sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go | 130-178 | Stripe calls inside the bulk transaction; `_ = lagoon.Transaction` (WR-01) | WARNING | lost Stripe ids on rollback, duplicate products |
| sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go | 89 | unconditional customer id UPDATE, no idempotency key (WR-02) | WARNING | duplicate Stripe customers under parallel checkout |
| sm-paymentgatewaysubscriptions-plugin/api/subscription.go | 87-93 | 409 guard only on active or trialing; not atomic (WR-03) | WARNING | possible double subscription |
| vue-paymentgateway-layer/app/utils/subscribe.ts | 85 | `redirecting` never reset (WR-07) | WARNING | button stuck after Back from Stripe (bfcache) |
| sm-paymentgatewaysubscriptions-plugin/go.work | 9 | names `../sm-summercmsio-app` (IN-08) | INFO | dev workspace path only; same convention as sm-pgstripe-plugin's go.work since Phase 18 |
| sm-paymentgatewaysubscriptions-plugin/classes/service.go | 373-395 | gateway-state write without SubscriptionLockKey (review WR-01) | WARNING | spurious `reactivated` reachable by a race |
| sm-paymentgatewaysubscriptions-plugin/classes/apply.go | 208-211 | unconditional SubscriptionCanceled after a fresh-state canceled apply (review WR-02) | WARNING | listener runs twice |
| sm-paymentgatewaysubscriptions-plugin/classes/service.go | 584-611 | SyncPlan unserialised on the detached path (review WR-03) | WARNING | duplicate Stripe products/prices on concurrent syncs |
| sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go | 129-144 | strict map vs fixed usd/eur/pln sync list; no amount check (review WR-05, WR-06) | WARNING | gbp/cad/aud/chf unsellable after a sync; stale amount billed |
| vue-paymentgateway-layer/tests/subscriptionComponents.test.ts | 423-440 | wiring asserted as source text (review IN-04) | INFO | component bfcache behaviour unexercised (human item 4) |
No unreferenced TBD, FIXME or XXX markers and no TODO, HACK or placeholder text in the phase's non-test files across the four repositories.
No TBD, FIXME, XXX, TODO or HACK markers in the phase's non-test source files I scanned (pgsub classes, api, models and controllers; pgstripe subscriptions; layer subscribe.ts; cabana crud.go; pact capabilities.go).
### Prohibitions (judgment tier, autonomous LLM verdict, non-authoritative)
| Plan | Prohibition | Verdict |
|------|-------------|---------|
| 01 | framework code and fixtures name no consuming application or payment key | holds: the v0.1.25..v0.1.26 diff of `modules/` and `docs/` adds no stripe, subscription or app names |
| 01 | existing field types and tree reorders byte-identical | holds per the gate (`TestReorderPostgres`, vitest 1401, check-admin-dist clean); flagged for human review |
| 02, 04 | pgsub imports no stripe-go | holds (`go list -deps`: 0) |
| 02, 03, 04 | no consuming-app names in code, comments, README or fixtures | holds for code, README and fixtures; the committed `go.work` names `sm-summercmsio-app` as a local path (INFO) |
| 02 | user subscriber writes only `stripe_customer_id` | holds (`UPDATE users SET stripe_customer_id = ? WHERE id = ?` only) |
| 03 | root pgstripe changes additive only | holds (`client.go` +19 lines; new migration ADD COLUMN IF NOT EXISTS) |
| 03, 06 | no test calls the network | holds per the plans' BackendOverride fake; not independently audited line by line; flagged |
| 04 | customer API takes no subscriber, subscription or redirect from the request | holds (`customer()` uses the JWT user; URLs from config; TestCustomerAPIAuthz) |
| 05 | layer adds no npm dependency; no v-html in the new components | holds |
| 06 | gate never green on a skip or missing Docker | holds (self-test passed; PHASE181_REQUIRE_POSTGRES=1 used) |
| 01, 06 | nothing pushed or tagged without the human's reply | recorded as replies in VALIDATION.md (hold, create-and-push, push-all); not independently verifiable from git; flagged |
| 07 | No Stripe call while a database transaction is open in the job or SyncSubscription | holds by reading: the retrieve runs inside WithSessionLock, before lagoon.Transaction, in both places. Event listeners inside Apply are outside this plan's code |
| 07 | pgsub imports no stripe-go (D-06) | holds (0 deps, run here) |
| 07, 08 | Nothing pushed or tagged by these plans | holds: the tags sit on plan 09's commits. Flagged as unverifiable from git timestamps alone |
| 08 | No customer API body, status for an existing case, route or event name changes | holds: the 409 and 422 reuse `errAlreadySubscribed` and `writeValidation`. The 409 now also covers past_due, unpaid, incomplete and paused, which the must-have specifies. Flagged |
| 08 | Framework Detached addition names no app, provider or subscription concept | holds: the `fc2a397` and `fa91e2a` diffs contain none of stripe, subscri, payment or app names |
| 08 | Another session's edits never staged or overwritten | not independently verifiable. The current summercms.go working tree holds other sessions' uncommitted STATE.md and state.json changes, untouched. Flagged |
| 09 | No test calls the network; test-only secrets | holds per the Stripe fake (BackendOverride). Not audited line by line. Flagged |
| 09 | Gate never green on a skip or missing Docker | holds: PHASE181_REQUIRE_POSTGRES=1, the self-test passed, 0 skip lines |
| 09 | Nothing pushed or tagged without the human's reply; no force-push | the reply is recorded verbatim in the 09 SUMMARY. Old tags v0.1.27 and v0.2.0 are untouched. Flagged |
### Human Verification Required
1. **Decision on CR-01 and CR-02.** Accept them as PHP parity (record overrides or a backlog item) or open a gap-closure phase before the Horoskopia cutover.
2. **Live Stripe test-mode checkout.** Run it end to end and confirm activation and a single `activated` event. Watch the created and updated delivery order.
3. **Billing-portal cancellation.** Check whether Stripe sends `cancel_at_period_end` or `cancel_at` (D-21).
4. **Browser pass.** Cover the admin taglist, flat reorder and sync toasts, and the layer components, including Back from Stripe Checkout (WR-07).
UAT evidence (Step 8b): `18.1-UAT.md` (2026-10-14) has all four rows pending, so no row settles an item. Row 1, the CR-01/CR-02 decision, was answered by the user's decision recorded in 18.1-CONTEXT.md § Amendments, and the gap closure implemented that decision, so it is dropped here. The UAT file is left as is.
1. **Disposition of the re-review warnings.** Decide WR-01 to WR-07 before the Horoskopia cutover. The most relevant are WR-02 (canceled fires twice), WR-01 (cancel race), WR-03 (duplicate products) and WR-06 (stale amount).
2. **Live Stripe test-mode checkout.** Confirm the fresh-retrieve path against real Stripe (`source=stripe` in the job log), a single `activated` and the 409 on a second subscribe.
3. **Billing-portal cancellation.** Check whether Stripe reports `cancel_at_period_end` or `cancel_at` (D-21).
4. **Browser pass.** Cover the admin screens and the layer components, and retest Back from Checkout and the portal now that WR-07 is fixed. This item also covers the one present-but-behavior-unverified truth.
### Gaps Summary
No success criterion failed. Every roadmap SC and every phase-local ID (SC-1 to SC-5, D-01 to D-22) has implementation evidence in the four repositories, and the phase gate ran green in this verification.
No success criterion or plan must-have failed. The gap closure did what it set out to do:
- **CR-01:** webhook jobs apply Stripe's current state under a per-subscription session lock, outside any transaction, and a deletion that arrives before creation is retried.
- **CR-02:** checkout never bills another currency's price.
- **WR-01, WR-02, WR-03, WR-04, WR-06 and WR-07:** each has code and a named test, and the gate requires all of them.
The two critical review findings reproduce the frozen PHP behaviour. D-21 explicitly chose that behaviour for event ordering, and Horoskopia's own checkout currency (pln) is unaffected. They therefore do not defeat "works as it does on PHP". They are money-relevant, and the Go runtime (4 concurrent workers) and the new customer API make them easier to reach. The status is `human_needed` rather than `passed` for two reasons: the product decision on CR-01 and CR-02, and the three manual items (live Stripe checkout, the portal `cancel_at` question, the browser pass). If the decision is to fix them, the review gives concrete fixes for CR-01 (re-retrieve in the job or an `event.created` watermark, retry ApplyDeleted on an unknown row) and CR-02 (refuse cross-currency fallback in the API and the gateway).
The full gate, with Postgres required, passed in this run, and so did both plugin suites and the framework's vet and short tests. The release (summercms v0.2.1, pgsub v0.1.0, pgstripe v0.1.0, layer v0.2.1) is on origin, and the plugin requires point at it.
The status is `human_needed`, not `passed`, for three reasons:
- **Live and browser items.** The three manual checks from the first verification are still open: live Stripe checkout, portal `cancel_at`, and the browser pass, which now also confirms the WR-07 fix.
- **Component wiring untested.** The WR-07 component wiring is present but not exercised by any behavioural test.
- **Re-review warnings.** The review found seven new warnings. They are side effects or residuals of the fixes, and some touch money or event listeners (double `canceled`, the cancel race, duplicate Stripe products on concurrent syncs, stale amounts). They do not defeat the goal as worded, but deciding whether they block the Horoskopia cutover is a product call.
---
_Verified: 2026-10-14T19:35:00Z_
_Verified: 2026-10-16T14:45:00Z_
_Verifier: Claude (msd-verifier)_