docs(18.1): add phase verification report and human UAT items

This commit is contained in:
Jakub Zych
2026-10-14 21:30:27 +02:00
parent 929cfd8669
commit 38c9c7aa3c
2 changed files with 267 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
---
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
---
## Current Test
number: 1
name: 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
awaiting: user response
## Tests
### 1. 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
result: [pending]
### 2. 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)
result: [pending]
### 3. 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
result: [pending]
### 4. 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)
result: [pending]
## Summary
total: 4
passed: 0
issues: 0
pending: 4
skipped: 0
blocked: 0
## Gaps

View File

@@ -0,0 +1,223 @@
---
phase: 18.1-payment-gateway-subscriptions
verified: 2026-10-14T19:35:00Z
status: human_needed
score: 5/5 roadmap success criteria verified (plan must-haves 41/41 verified; 0 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
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-02-PLAN.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-03-PLAN.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-03-SUMMARY.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-04-PLAN.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md
- .planning/phases/18.1-payment-gateway-subscriptions/18.1-05-PLAN.md
- .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
- admin/src/components/form/fields/TaglistField.vue
- admin/src/views/ReorderView.vue
- modules/cabana/crud.go
- modules/cabana/field_taglist.go
- modules/cabana/form_schema.go
- modules/cabana/list_schema.go
- modules/cabana/reorder.go
- scripts/check-phase18.1.sh
covered_digest: "v2:sha256:3f6d8db93c3ed3d1a76a29d5111f67aa4333965ceacec7e0571c0c30035b0e70"
behavior_unverified: 0
overrides_applied: 0
coincidental_reliance_items:
- truth: "SC-4: duplicate-event idempotency"
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."
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"
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)"
why_human: "vitest and SSR specs do not drive a real browser or the back/forward cache"
---
# 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
**Status:** human_needed
**Re-verification:** No, initial verification
## Goal Achievement
### Observable Truths (roadmap success criteria)
| # | 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. |
**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.
### 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`. |
No orphaned requirements: REQUIREMENTS.md has no rows for Phase 18.1.
### Required Artifacts
| 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 |
### Key Link Verification
| 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 |
### 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 |
### 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 |
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)
**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.
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.
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.
### 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 |
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.
### 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 |
### 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).
### 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.
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).
---
_Verified: 2026-10-14T19:35:00Z_
_Verifier: Claude (msd-verifier)_