diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 3c647ea..d37be81 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1173,6 +1173,15 @@ Plans: **Wave 4** *(blocked on 18.1-01..18.1-05)* - [x] 18.1-06-PLAN.md — Unit tests last across all repositories, PHP test map, `scripts/check-phase18.1.sh`, validated 18.1-VALIDATION.md, final push approval +**Gap closure wave 1** *(from 18.1-REVIEW.md; the user chose fix over PHP parity on 2026-10-14)* +- [ ] 18.1-07-PLAN.md — Webhook and sync state (pgsub, pgstripe): the job applies the subscription Stripe holds under a per-subscription advisory lock (CR-01, D-21 amended for ordering); a deletion before creation is retried; plan fallback by product and plan_code (WR-04); SyncSubscription writes only gateway-owned columns and refreshes the plan (WR-06) + +**Gap closure wave 2** *(blocked on 18.1-07)* +- [ ] 18.1-08-PLAN.md — Checkout, admin sync and layer: no cross-currency price (CR-02), first-write-wins customer id and idempotency keys (WR-02), guarded checkout for any open subscription (WR-03), framework `pact.AdminBulkAction.Detached` and plan sync outside transactions (WR-01), buttons recover after Back (WR-07) + +**Gap closure wave 3** *(blocked on 18.1-07, 18.1-08)* +- [ ] 18.1-09-PLAN.md — Unit tests last for the gap closure across four repositories, the extended gate, VALIDATION rows, layer 0.2.1, final push approval (framework v0.1.27, layer v0.2.1, optional first plugin tags) + ### Phase 19: Chat and ChatChannels **Goal:** `Golem15.Chat` and `Golem15.ChatChannels` run on SummerCMS as shared plugins, with realtime delivery through `lighthouse`, ready for the Wavepath port. diff --git a/.planning/STATE.md b/.planning/STATE.md index 98b040a..bc42f07 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,17 +2,17 @@ msd_state_version: "1.0" milestone: v1.0 current_phase: "18.1" -current_phase_name: Payment Gateway Subscriptions (INSERTED) -status: verifying +current_phase_name: payment-gateway-subscriptions +status: executing stopped_at: Completed 18.1-06-PLAN.md (phase 18.1 complete, released) -last_updated: "2026-10-14T19:14:01.421Z" +last_updated: "2026-10-14T20:20:48.559Z" last_activity: 2026-10-14 last_activity_desc: Phase 18.1 execution started -state_head: c1a65c9f227c75368ea91188df05bbd8ebc195cd +state_head: 38c9c7aa3c4d27cfeffa23052958c3609b88c610 progress: total_phases: 40 completed_phases: 31 - total_plans: 182 + total_plans: 185 completed_plans: 182 gsd_state_version: "1.0" milestone_name: milestone @@ -29,9 +29,9 @@ See: .planning/PROJECT.md (updated 2026-10-12) ## Current Position -Phase: 18.1 (Payment Gateway Subscriptions (INSERTED)) — EXECUTING +Phase: 18.1 (payment-gateway-subscriptions) — READY TO EXECUTE Plan: 6 of 6 -Status: Phase complete — ready for verification +Status: Ready to execute Last activity: 2026-10-14 — Phase 18.1 execution started Progress: [████░░░░░░] 44% diff --git a/.planning/phases/18.1-payment-gateway-subscriptions/18.1-07-PLAN.md b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-07-PLAN.md new file mode 100644 index 0000000..ad5f004 --- /dev/null +++ b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-07-PLAN.md @@ -0,0 +1,281 @@ +--- +phase: 18.1-payment-gateway-subscriptions +plan: 07 +type: execute +wave: 1 +depends_on: [] +files_modified: + - ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go + - ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go + - ../sm-paymentgatewaysubscriptions-plugin/classes/service.go + - ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go + - ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go + - ../sm-paymentgatewaysubscriptions-plugin/README.md + - ../sm-pgstripe-plugin/subscriptions/gateway.go + - ../sm-pgstripe-plugin/subscriptions/job.go + - ../sm-pgstripe-plugin/subscriptions/decode.go + - ../sm-pgstripe-plugin/subscriptions/decode_test.go + - ../sm-pgstripe-plugin/subscriptions/host_test.go + - ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go + - ../sm-pgstripe-plugin/subscriptions/README.md + - .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md + - .planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md +autonomous: true +gap_closure: true +requirements: [SC-2, SC-4, D-11, D-21, CR-01, WR-04, WR-06] +estimate: + tokens: 120000 + raw_tokens: 120000 + tasks: 3 + confidence: low +must_haves: + truths: + - "CR-01 (stale snapshot, review sequence 1): for customer.subscription.created and .updated the webhook job applies the subscription state Stripe holds at the time the job runs (Gateway.RetrieveSubscription, a Stripe call made outside any database transaction), not the event's data.object; while the job retrieves and applies, it holds a session-level Postgres advisory lock keyed by classes.SubscriptionLockKey(subscription id), so two jobs about one subscription never interleave. A stored active row therefore cannot be set back to incomplete by an older or concurrently retried event, and TestFreshStateSmoke/stale_updated_after_active proves it on Postgres." + - "CR-01 (deleted before created, review sequence 2): ApplyDeleted on a subscription id with no stored row returns an error wrapping classes.ErrSubscriptionNotStored, so the job rolls its marker back and River retries it instead of committing the marker; a created event retried afterwards stores the state Stripe reports (canceled), so a deleted subscription never comes back active and never fires activated." + - "CR-01 (spurious reactivated, review sequence 3): after Service.Cancel set cancel_at_period_end locally, an older queued customer.subscription.updated carrying cancel_at_period_end false applies Stripe's current value (true), so SubscriptionReactivated does not fire." + - "CR-01 claim-first is kept (D-11, STRIPE-006/007): a duplicate event is answered from the processed-event marker before any Stripe call, an event of another application (metadata app) is skipped with its marker kept and without a Stripe call, a failed retrieval or a failed Apply leaves no marker and returns the error, and when Stripe answers that the subscription does not exist (404 or resource_missing, ErrSubscriptionMissing) the job applies the event's own snapshot with a Warn log, as before." + - "WR-04: Apply resolves the plan by the exact price id (stripe_price_id, then stripe_prices), else by the Stripe product id of the subscribed price (stripe_product_id), else by the plan_code metadata Checkout wrote (any plan with that code, active or not), so a subscription created on a price that a later sync archived, or on a plan whose code was changed, is stored; contracts.Update carries ProductID and PlanCode, SubscriptionArgs carries product_id and plan_code, and job arguments still hold no e-mail, name or address." + - "WR-06: Service.SyncSubscription retrieves outside any transaction, then in one short transaction re-reads the row FOR UPDATE, writes only the gateway-owned columns (stripe_status, cancel_at_period_end, both period bounds, canceled_at, ended_at, trial_ends_at, stripe_customer_id) and, when the gateway implements contracts.SubscriptionRetriever, subscription_plan_id from the retrieved price (same resolution as Apply), under the same per-subscription advisory lock as the job; a plan switch, subscriber or customer id written between the caller's load and the sync is never overwritten, and no event fires (PHP reconcile parity)." + - "D-21 is amended for ordering by this gap closure (user decision 2026-10-14 on verification human item 1): out-of-order events no longer behave as on PHP; the READMEs' 'Out-of-order events' lines and 18.1-CONTEXT.md § Amendments say so. D-21's other defaults (cancel_at_period_end mapping, both payload shapes, latest subscription with has_access) are unchanged." + - "Compatibility: no route, event name, table, column, config key or customer API response shape changes; contracts.Gateway keeps its eight methods (the retriever is an optional interface); pgsub still links no stripe-go (D-06); every commit keeps go vet and go test ./... green in both plugin repositories, existing assertions that encoded the replaced behaviour are adapted in the same commit and named in the SUMMARY, and nothing is pushed or tagged (release is plan 09)." + artifacts: + - path: "../sm-paymentgatewaysubscriptions-plugin/classes/lock.go" + provides: "session-level advisory lock helper and the lock keys" + contains: "pg_advisory_lock" + - path: "../sm-pgstripe-plugin/subscriptions/gateway.go" + provides: "RetrieveSubscription and ErrSubscriptionMissing" + contains: "RetrieveSubscription" + - path: "../sm-pgstripe-plugin/subscriptions/job.go" + provides: "fresh-state application under the subscription lock, deletion retry" + contains: "SubscriptionLockKey" + - path: "../sm-paymentgatewaysubscriptions-plugin/classes/apply.go" + provides: "ErrSubscriptionNotStored and the price, product, plan-code plan resolution" + contains: "ErrSubscriptionNotStored" + - path: "../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go" + provides: "Postgres smoke of the stale-event, deletion and sync paths" + contains: "TestFreshStateSmoke" + key_links: + - from: "../sm-pgstripe-plugin/subscriptions/job.go" + to: "../sm-pgstripe-plugin/subscriptions/gateway.go" + via: "work calls NewGateway(p.app).RetrieveSubscription before the claim-first transaction" + pattern: "RetrieveSubscription" + - from: "../sm-pgstripe-plugin/subscriptions/job.go" + to: "../sm-paymentgatewaysubscriptions-plugin/classes/lock.go" + via: "classes.WithSessionLock(ctx, db, classes.SubscriptionLockKey(id), ...)" + pattern: "WithSessionLock" + - from: "../sm-paymentgatewaysubscriptions-plugin/classes/service.go" + to: "../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go" + via: "SyncSubscription type-asserts contracts.SubscriptionRetriever" + pattern: "SubscriptionRetriever" + prohibitions: + - statement: "No Stripe call is made while a database transaction is open in the job or in Service.SyncSubscription." + status: unresolved + verification: judgment + - statement: "pgsub imports no stripe-go package (D-06)." + status: unresolved + verification: automated + - statement: "Nothing is pushed or tagged by this plan." + status: unresolved + verification: judgment +--- + +## Phase Goal + +ROADMAP Goal (not in user-story format; no story is invented, as in plans 01 to 06): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP. + +This plan's slice (gap closure 1 of 3, wave 1): the webhook and sync state findings of `18.1-REVIEW.md`: CR-01 (stale event snapshots, including the deletion retry), WR-04 (subscriptions on archived prices or renamed plans never stored) and WR-06 (SyncSubscription's stale full-row save without a plan refresh). The user decided on 2026-10-14 (verification human item 1) to fix these rather than accept them as PHP parity. + +## Decision amendment: D-21 superseded for event ordering + +D-21 said "out-of-order events behave as PHP does". The user's 2026-10-14 decision on verification human item 1 supersedes that clause for ordering: the job now applies the state Stripe holds (re-retrieved), serialised per subscription, and a deletion that arrives before its subscription is stored is retried. This is a deliberate departure from PHP behaviour, recorded here, in both READMEs and in `18.1-CONTEXT.md` § Amendments (Task 3). D-21's other defaults stay: PHP's `cancel_at_period_end` mapping, both payload shapes, and `GET /subscription` answering the latest subscription with `has_access` and `in_grace_period`. + +## Decisions taken in this plan (Claude's discretion) + +- **Re-retrieve, not a watermark.** Stripe's `event.created` has one-second resolution, and Checkout emits `created` and `updated` within the same second, so a watermark cannot order exactly the race the review describes. Re-retrieving needs no schema change, so no migration is added. +- **Retrieval outside the transaction, serialised by a session-level advisory lock.** `pg_advisory_lock` runs on a connection pinned with GORM's `DB.Connection`; no transaction is open during the Stripe call. The claim-first transaction runs on the same connection after the call. The lock is released in a deferred unlock that ignores request cancellation, and closing the connection releases it too. +- **Stripe 404 falls back to the snapshot.** A signed event names a subscription of the same account and mode, so a 404 is near-impossible. If it happens, the PHP behaviour (apply the snapshot) is the floor, so no event is silently dropped. Existing tests whose Stripe fake holds no object for an event keep this path, and the host helper sets the fake's state to each event's object (Task 1) so the host flow runs the fresh path. +- **Plan fallback order: price, product, plan_code.** The product id survives repricing and code changes (`upsertProduct` updates one product per plan), and the `plan_code` metadata covers a plan whose product id was never stored. +- **Optional interface, not a ninth Gateway method.** `contracts.SubscriptionRetriever` keeps the published `contracts.Gateway` interface unchanged for other implementers (D-05 Google Play stays possible). + +## Artifacts this phase produces (this plan) + +- `../sm-paymentgatewaysubscriptions-plugin/classes/lock.go` (new): `WithSessionLock(ctx context.Context, db *gorm.DB, key string, fn func(conn *gorm.DB) error) error`, `SubscriptionLockKey(gatewaySubscriptionID string) string`, unexported const `lockKeyPrefix`. +- `../sm-paymentgatewaysubscriptions-plugin/classes/apply.go`: `ErrSubscriptionNotStored`; unexported `resolvePlan(db *gorm.DB, u contracts.Update) (*models.SubscriptionPlan, string, error)`, `planForProduct`, `planByCode`; `Apply` and `ApplyDeleted` use them. +- `../sm-paymentgatewaysubscriptions-plugin/classes/service.go`: `SyncSubscription` rewritten (retrieve outside the transaction, lock, gateway-owned columns, plan refresh); unexported `gatewayFields`. +- `../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go`: `Update.ProductID`, `Update.PlanCode`, interface `SubscriptionRetriever` with `RetrieveSubscription(ctx context.Context, subscriptionID string) (Update, error)`. +- `../sm-pgstripe-plugin/subscriptions/gateway.go`: `ErrSubscriptionMissing`, `(*Gateway).RetrieveSubscription`, `SyncSubscription` built on it, the assertion `var _ contracts.SubscriptionRetriever = (*Gateway)(nil)`, `MetadataPlanCode` used for the checkout metadata key. +- `../sm-pgstripe-plugin/subscriptions/decode.go`: const `MetadataPlanCode`; `subscriptionItem.Price.Product`; `SubscriptionArgs.ProductID` (`product_id`) and `SubscriptionArgs.PlanCode` (`plan_code`) filled by `args()`. +- `../sm-pgstripe-plugin/subscriptions/job.go`: `work` re-retrieves created and updated events under the subscription lock, runs deletions under it, maps `ProductID` and `PlanCode` in `update()`; unexported `applySubscription`. +- `../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go` (new): `TestFreshStateSmoke` with subtests `stale_updated_after_active`, `missing_at_stripe_applies_snapshot`, `deleted_before_created`, `repriced_plan_by_product`, `sync_keeps_plan_switch`. +- Adapted tests: `host_test.go` (`subEvent` stores the event's object in the fake), `decode_test.go` (`TestJobArgsMinimal` key list gains `plan_code` and `product_id`), pgsub `core_smoke_test.go` (`event_diff` expects `ErrSubscriptionNotStored` for an unknown deletion). +- Docs: both READMEs (features, API reference, deviations), `COVERAGE.md` (subscriptions.retrieve row), `18.1-CONTEXT.md` (D-21 amendment). + + +Close CR-01, WR-04 and WR-06: the webhook job and the reconcile sync apply the state Stripe holds, serialised per subscription, never a stale snapshot over newer data; a deletion that arrives before its subscription is stored is retried; a billed subscription on an archived price or a renamed plan is stored; and a sync never overwrites columns it does not own. D-21 is superseded for ordering per the user's 2026-10-14 decision. + +Purpose: money-relevant correctness of access (a paid customer must not lose access, a canceled one must not regain it) before any production cutover. +Output: commits in sm-paymentgatewaysubscriptions-plugin and sm-pgstripe-plugin (code, adapted tests, READMEs), one planning-docs commit in summercms.go (COVERAGE, CONTEXT amendment). New unit-test coverage is plan 09; this plan carries one Postgres smoke test (`TestFreshStateSmoke`). + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-VERIFICATION.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-03-SUMMARY.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-06-SUMMARY.md + +Repositories: ../sm-paymentgatewaysubscriptions-plugin (pgsub) and ../sm-pgstripe-plugin (pgstripe). Both require summercms v0.1.26 with a `replace` onto ../summercms.go; pgstripe replaces pgsub with ../sm-paymentgatewaysubscriptions-plugin, so a pgsub change is visible to pgstripe without a commit. Commit per repository (pgsub first when both change), stage files by explicit path, no co-author tags, never push. Docker-backed suites skip under -short; run them on a Docker host (`sudo -u golem` if the socket is denied although the user is in the docker group). + + +- `classes.Service.Apply(ctx, tx, contracts.Update) (*models.Subscription, error)`: classes/apply.go:28-126; the row lookup FOR UPDATE at :52, `planForPrice` at :300-323, `loadPlan` at :326. +- `classes.Service.ApplyDeleted(ctx, tx, gateway, subscriptionID string)`: classes/apply.go:170-205; the unknown-row branch at :183-187 answers nil, nil today. +- `classes.Service.ClaimEvent(ctx, tx, eventID, eventType) (bool, error)` :255 and `EventProcessed(ctx, db, eventID) (bool, error)` :271. +- `classes.Service.SyncSubscription(ctx, sub)`: classes/service.go:335-352 (full-row Save today); `gatewayOrStripe` :249; `lagoon.Transaction` usage pattern in `ensureCustomer` :403. +- `contracts.Gateway` (8 methods) and `contracts.Update`: contracts/gateway.go:24-109. +- pgstripe `subscriptions.Gateway`: `client(ctx)` gateway.go:56, `SyncSubscription` :263-306 (decodes `remote.LastResponse.RawJSON` with `decodeSubscription`), `failed` :328, checkout metadata `plan_code` literal :153. +- pgstripe `work` job.go:119-147, `route` :152-182, `(*SubscriptionArgs).update()` :187-205; `decodeSubscription`, `subscriptionObject.args()` decode.go:103, :145; metadata consts decode.go:21-25. +- Test helpers (package subscriptions_test): `bootSubscriptionsHost` host_test.go:91, `subEvent` :393, `deliver` :316, `work` :307, `activate` :377, `subscription` :330, `recorder` :220, `plan` :204; fake `setSubscription` stripefake_test.go:190, `GET /v1/subscriptions/{id}` answers 404 `No such subscription` when unset (:196-204); `subscriptionObject(subFields)` eventbody_test.go:38. +- GORM v1.31.2 `(*gorm.DB).Connection(fc func(tx *gorm.DB) error) error` pins one pooled connection (finisher_api.go:613). Postgres `hashtextextended(text, bigint)` gives the bigint key for `pg_advisory_lock`/`pg_advisory_unlock`. + + + + + + + Task 1: Tracer: a stale customer.subscription.updated can no longer overwrite the state Stripe holds (retrieve outside the transaction, per-subscription advisory lock, claim-first apply) + Internal job behaviour behind unchanged routes, events and tables; reverting the commit restores snapshot application. + ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/job.go, ../sm-pgstripe-plugin/subscriptions/host_test.go, ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go, ../sm-pgstripe-plugin/subscriptions/README.md + ../sm-pgstripe-plugin/subscriptions/job.go (whole file), ../sm-pgstripe-plugin/subscriptions/gateway.go (client, SyncSubscription, failed), ../sm-pgstripe-plugin/subscriptions/decode.go (decodeSubscription, args), ../sm-pgstripe-plugin/subscriptions/plugin.go (db, service, log accessors), ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go (ClaimEvent, EventProcessed), ../sm-pgstripe-plugin/subscriptions/host_test.go (bootSubscriptionsHost, subEvent, deliver, work, activate), ../sm-pgstripe-plugin/subscriptions/stripefake_test.go (setSubscription, getSubscription), ../sm-pgstripe-plugin/subscriptions/eventbody_test.go (subFields, subscriptionObject), ../sm-pgstripe-plugin/subscriptions/README.md (Features: Job, Sync; Deviations), .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (CR-01 in full) + Implements CR-01's preferred fix (re-retrieve in the job) and supersedes D-21 for ordering per the user's 2026-10-14 decision; D-11's claim-first transaction is kept. + +pgsub (commit first): create classes/lock.go with `WithSessionLock(ctx, db, key, fn)`: it pins one connection with `db.WithContext(ctx).Connection`, runs `SELECT pg_advisory_lock(hashtextextended(?, 0))` with key on it, calls fn with that connection, and in a deferred step runs `SELECT pg_advisory_unlock(hashtextextended(?, 0))` on the same connection through `context.WithoutCancel(ctx)`, logging (ids only) an unlock error at Error. A nil db or empty key is an error. Add `SubscriptionLockKey(gatewaySubscriptionID)` returning `lockKeyPrefix + "subscription:" + id` with `lockKeyPrefix = "golem15.paymentgatewaysubscriptions:"`. Package doc of the file: a session-level lock, never held inside a transaction, so a provider call may run under it. + +pgstripe gateway.go: add `ErrSubscriptionMissing` (Stripe answered that the subscription does not exist). Add exported `RetrieveSubscription(ctx, subscriptionID) (contracts.Update, error)`: an empty id is an error; it builds the client with `g.client(ctx)`, calls `sc.V1Subscriptions.Retrieve(ctx, id, nil)`, maps a `*stripe.Error` with HTTP status 404 or code `resource_missing` to an error wrapping ErrSubscriptionMissing (logged through `g.failed`), requires `remote.LastResponse.RawJSON`, decodes it with `decodeSubscription` and returns `obj.args().update()` (the job's own mapping, so the subscriber and app metadata come from Stripe). Keep `SyncSubscription` unchanged in this task. + +pgstripe job.go: in `work`, before the existing transaction, handle created and updated events whose Subscription has an id through a new unexported `applySubscription(ctx, svc, db, args)`: (1) `svc.EventProcessed(ctx, db, args.EventID)`; an error is returned, true logs "event already processed" at Info and returns nil without a Stripe call; (2) when the app tag differs from `metadataApp` (same condition as today) fall through to the existing transaction path, which claims and skips with the marker kept (STRIPE-007) and makes no Stripe call; (3) otherwise run `classes.WithSessionLock(ctx, db, classes.SubscriptionLockKey(s.ID), fn)` where fn calls `NewGateway(p.app).RetrieveSubscription(ctx, s.ID)`; on an error wrapping ErrSubscriptionMissing it logs at Warn ("Stripe has no such subscription; the event snapshot is applied", event id and subscription id) and uses `s.update()`; on any other error it returns the error (no marker, River retries); then it runs `lagoon.Transaction(ctx, conn, ...)` that claims the marker with `svc.ClaimEvent` (not claimed: return nil) and calls `svc.Apply(ctx, tx, u)`. The job's Error log on failure stays as it is. Deleted and invoice events keep today's path in this task. + +Test adaptation (same pgstripe commit, behaviour-preserving): in host_test.go `subEvent`, store the event's object in the fake with `e.fake.setSubscription(...)` before returning the body, so Stripe holds the state each delivered event describes (real Stripe semantics) and the host flow exercises the fresh path; run the full subscriptions suite and adapt only assertions the fresh path legitimately changes, naming each in the SUMMARY. + +Smoke (allowed in a non-final plan): create subscriptions/fresh_state_smoke_test.go, package subscriptions_test, `TestFreshStateSmoke` on `bootSubscriptionsHost`: subtest `stale_updated_after_active` activates a subscription (`e.activate`), sets the fake's object to active, delivers a customer.subscription.updated whose snapshot says incomplete (built with `eventBody` and `subscriptionObject` directly, not `subEvent`, so the fake keeps active), and asserts the row stays active and no subscription event fires; subtest `missing_at_stripe_applies_snapshot` delivers a created event for an id the fake does not hold and asserts the row is stored with the snapshot's status. Later tasks add subtests. + +README (pgstripe subscriptions, same commit): Features "Job" describes the fresh retrieval, the lock and the snapshot fallback on a 404; API reference lists `RetrieveSubscription` and `ErrSubscriptionMissing`. pgsub README API reference lists `WithSessionLock` and `SubscriptionLockKey` in the pgsub commit. + + go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... + non-zero exit: vet reports an issue or a Docker-free test fails in either plugin repository + docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestFreshStateSmoke|TestHostSubscriptionFlow|TestSubscriptionJob|TestStripeSubscriptionReliability|TestSubscriptionWebhookSmoke)$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 5 + non-zero exit: no Docker daemon (record NOT RUN), or fewer than five top-level PASS lines (a stale snapshot regressed the row, the host flow or a STRIPE-006/007 job case broke, or a test skipped) + + + - `grep -n "pg_advisory_lock\|pg_advisory_unlock\|WithoutCancel" ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go` prints at least three lines. + - `grep -n "func (g \*Gateway) RetrieveSubscription" ../sm-pgstripe-plugin/subscriptions/gateway.go` prints one line and `grep -n "ErrSubscriptionMissing" ../sm-pgstripe-plugin/subscriptions/job.go` prints at least one line. + - `grep -n "SubscriptionLockKey" ../sm-pgstripe-plugin/subscriptions/job.go` prints at least one line. + - `grep -n "func TestFreshStateSmoke" ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go` prints one line. + - `test "$(go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./... | grep -c stripe-go)" = 0` succeeds (D-06). + - `grep -n "RetrieveSubscription" ../sm-pgstripe-plugin/subscriptions/README.md` prints at least one line. + + A created or updated event applies the state Stripe holds under the per-subscription lock, end to end from the signed webhook through the River job to the stored row, and the stale-update smoke passes on Postgres. + + + + Task 2: Deletion before creation is retried, and a billed subscription on an archived price or a renamed plan is stored (CR-01 sequence 2, WR-04) + `contracts.Update` gains two fields and `ApplyDeleted` answers a new sentinel error for an unknown row: additive to a published plugin API, but any caller that relied on nil, nil for an unknown deletion sees an error. + ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go, ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go, ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-pgstripe-plugin/subscriptions/decode.go, ../sm-pgstripe-plugin/subscriptions/decode_test.go, ../sm-pgstripe-plugin/subscriptions/job.go, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go, ../sm-pgstripe-plugin/subscriptions/README.md + ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go (whole file), ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go (Update), ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go (event_diff subtest, the sub_never_seen deletion around :183-195), ../sm-pgstripe-plugin/subscriptions/decode.go (subscriptionItem, args, metadata consts), ../sm-pgstripe-plugin/subscriptions/decode_test.go (TestJobArgsMinimal subKeys list around :224), ../sm-pgstripe-plugin/subscriptions/job.go (route, update), ../sm-pgstripe-plugin/subscriptions/gateway.go (CreateCheckoutSession metadata), ../sm-pgstripe-plugin/subscriptions/sync_plan.go (upsertProduct: one product per plan; the lookup key and transfer_lookup_key), .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (CR-01 sequence 2 and its ApplyDeleted fix; WR-04 in full) + Implements CR-01's deletion fix and WR-04; D-08 (an unknown subscriber alias fails loudly) and D-11 are unchanged. + +pgsub (commit first): contracts/gateway.go `Update` gains `ProductID string` (the gateway's product of the subscribed price; used to find the plan when the price id is unknown) and `PlanCode string` (the plan code the checkout recorded; the last fallback). classes/apply.go: add `ErrSubscriptionNotStored` ("the gateway subscription has no stored row yet"); `ApplyDeleted` on an unknown row keeps its Warn log and returns an error wrapping ErrSubscriptionNotStored that names the gateway subscription id (no row change, no event), and its doc says the caller retries. Replace the plan lookup in `Apply` with unexported `resolvePlan(db, u)` returning the plan and how it matched: `planForPrice` (unchanged, exact), else `planForProduct(db, u.ProductID)` (`stripe_product_id = ?`, lowest id), else `planByCode(db, u.PlanCode)` (`code = ?`, active or not); an empty id or code skips its step. A match by product or code logs at Warn with the subscription id, the unknown price id and the matched plan code; no match keeps today's behaviour (the stored plan when the row has one, else the "no plan holds price id" error naming the price, product and code). Update the doc comments of Apply and ApplyDeleted. Adapt core_smoke_test.go `event_diff`: the `sub_never_seen` deletion now expects `errors.Is(err, classes.ErrSubscriptionNotStored)`, a nil row and no event (the transaction helper must not fail the test on that expected error; use the existing `env.txErr` form). README (pgsub): API reference lists `ErrSubscriptionNotStored`, `Update.ProductID`, `Update.PlanCode`; Usage or Features states the plan resolution order. + +pgstripe (second commit): decode.go adds `MetadataPlanCode = "plan_code"` to the metadata consts, `Product pgstripe.ExpandableID` (json `product`) on `subscriptionItem.Price`, and `SubscriptionArgs.ProductID` (json `product_id,omitempty`) and `SubscriptionArgs.PlanCode` (json `plan_code,omitempty`) filled by `args()` from the first item's price product and `Metadata[MetadataPlanCode]`; the doc of WebhookJobArgs lists the two new keys (ids and a code, no personal data). job.go `update()` maps both onto `contracts.Update`. gateway.go writes the checkout metadata key through `MetadataPlanCode` (same value). Move deleted events under the subscription lock: in `work`, a deleted event with an id runs `classes.WithSessionLock(ctx, db, classes.SubscriptionLockKey(s.ID), ...)` around the existing claim-first transaction on the pinned connection, so it serialises with created and updated jobs; an ErrSubscriptionNotStored from ApplyDeleted rolls the marker back and is returned (River retries; the job's Error log names it). Adapt decode_test.go `TestJobArgsMinimal`: the subscription key list gains `plan_code` and `product_id` (still no e-mail, name or address key). Extend `TestFreshStateSmoke` with `deleted_before_created` (a deleted event for a subscription with no row returns an error from Work and leaves no marker; after a created event whose fake state is canceled is applied, the retried deletion succeeds, the row is canceled, has_access is false and activated never fired) and `repriced_plan_by_product` (a plan whose stripe_prices no longer hold the subscription's price but whose stripe_product_id equals the price's product stores the subscription on that plan). README (subscriptions): job arguments list, the deletion retry, and the plan fallback. + + go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... + non-zero exit: vet reports an issue, TestJobArgsMinimal finds an unexpected key, or another Docker-free test fails + docker info >/dev/null 2>&1 && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 ./... && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestFreshStateSmoke|TestHostSubscriptionFlow|TestSubscriptionJob|TestStripeSubscriptionReliability)$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 4 + non-zero exit: no Docker daemon (record NOT RUN), a pgsub Postgres test fails (TestApplyEvents, TestSubscriptionsCoreSmoke and the rest of the root package), or fewer than four pgstripe top-level PASS lines + + + - `grep -n "ErrSubscriptionNotStored" ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go` prints at least two lines. + - `grep -n "func resolvePlan\|func planForProduct\|func planByCode" ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go` prints three lines. + - `grep -n "ProductID\|PlanCode" ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go` prints at least two lines. + - `grep -n "MetadataPlanCode" ../sm-pgstripe-plugin/subscriptions/decode.go ../sm-pgstripe-plugin/subscriptions/gateway.go` prints at least two lines. + - `grep -n '"plan_code"\|"product_id"' ../sm-pgstripe-plugin/subscriptions/decode_test.go` prints at least one line. + - `grep -n "deleted_before_created\|repriced_plan_by_product" ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go` prints two lines. + + A deletion that outruns its subscription is retried instead of lost, the retried creation stores Stripe's canceled state, and a subscription on an archived price or a renamed plan is stored on the right plan. + + + + Task 3: SyncSubscription writes only gateway-owned columns of a locked, fresh row and refreshes the plan (WR-06); D-21 amendment recorded in the READMEs, COVERAGE and CONTEXT + Adds the optional `contracts.SubscriptionRetriever` interface and changes what `Service.SyncSubscription` writes (no longer the whole row): published plugin API, additive, documented. + ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go, ../sm-pgstripe-plugin/subscriptions/README.md, .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md, .planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md + ../sm-paymentgatewaysubscriptions-plugin/classes/service.go (SyncSubscription :335-352, gatewayOrStripe, ensureCustomer for the short-transaction pattern), ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go (resolvePlan from Task 2, loadPlan), ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go, ../sm-paymentgatewaysubscriptions-plugin/service_api_test.go (subtest "sync subscription saves the gateway state without events" around :480, which uses the fixture gateway without a retriever), ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go (fakeGateway.SyncSubscription :199), ../sm-pgstripe-plugin/subscriptions/gateway.go (SyncSubscription, RetrieveSubscription from Task 1), ../sm-pgstripe-plugin/subscriptions/host_test.go (sync_subscription subtest :874), ../sm-paymentgatewaysubscriptions-plugin/README.md (Differences from PHP, API reference), ../sm-pgstripe-plugin/subscriptions/README.md (Sync, Deviations from PHP), .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md, .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (WR-06) + Implements WR-06 and records the D-21 ordering amendment. + +pgsub (commit first): contracts/gateway.go adds the optional interface `SubscriptionRetriever` with `RetrieveSubscription(ctx context.Context, subscriptionID string) (Update, error)` (doc: a Gateway may implement it to report the provider's current state as an Update; the service uses it to sync and to map the plan; it never writes the database). classes/service.go rewrites `SyncSubscription(ctx, sub)` with the same signature: a nil sub or a sub without a gateway subscription id is an error; the gateway resolves as today; everything runs inside `WithSessionLock(ctx, s.db, SubscriptionLockKey(id), ...)` so a sync never interleaves with a webhook job of the same subscription. Inside the lock, outside any transaction: when the gateway implements SubscriptionRetriever, call it and keep the Update; otherwise copy the caller's row, call `gw.SyncSubscription(ctx, ©)` and derive an Update from the copy's gateway-owned fields (no plan refresh on that path). Then one `lagoon.Transaction` on the pinned connection: re-read the row by primary key FOR UPDATE (none is an error naming the id), apply the gateway-owned fields through unexported `gatewayFields(row, u)` (stripe_status only when it is one of models.Statuses and non-empty; cancel_at_period_end as reported; current_period_start, current_period_end, canceled_at, ended_at, trial_ends_at only when non-nil, never cleared; stripe_customer_id only when non-empty), set subscription_plan_id from `resolvePlan` when the retriever path resolved a plan, save with `Select` of exactly those columns plus updated_at (never a full-row Save), load the plan with `loadPlan`, and copy the saved row into the caller's `*sub`. No event fires (PHP reconcile parity). Update the doc comment and the README: API reference lists `SubscriptionRetriever`; "Differences from PHP" replaces "Out-of-order gateway events behave as on PHP: the last applied state wins" with the new rule (the job applies the provider's current state serialised per subscription; a deletion before its subscription is stored is retried; superseding D-21 for ordering by the 2026-10-14 decision) and adds that a sync writes only the gateway-owned columns and refreshes the plan. + +pgstripe (second commit): gateway.go asserts `var _ contracts.SubscriptionRetriever = (*Gateway)(nil)` and reimplements `SyncSubscription` on top of `RetrieveSubscription` (same in-memory fields as today, so the contract method keeps its behaviour). Extend `TestFreshStateSmoke` with `sync_keeps_plan_switch`: load a subscription, then switch its plan and customer id in the database (as Apply would), set the fake's object to a new status on the second plan's price, call `svc.SyncSubscription` with the stale loaded copy, and assert the row has the fake's status, the second plan, the switched customer id, and that the caller's copy now matches the row; no event fires. README (subscriptions): "Sync" describes the retriever path; "Deviations from PHP" replaces the "Out-of-order events" line with the fresh-state rule, the 404 snapshot fallback, the deletion retry and the D-21 amendment. + +Planning docs (summercms.go, own commit, staged by path): COVERAGE.md row "subscriptions.retrieve (syncSubscription / reconcile)" keeps INTEGRATE and its reason names the webhook job's fresh-state retrieval (CR-01, plan 18.1-07). 18.1-CONTEXT.md § Amendments gains one bullet: "D-21 amendment (2026-10-14, gap closure 18.1-07, user decision on verification human item 1): out-of-order events no longer behave as PHP; created and updated events apply the subscription Stripe holds, serialised per subscription, and a deletion before its subscription is stored is retried. D-21's other defaults are unchanged." Do not edit plans 01 to 06. + + go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... + non-zero exit: vet reports an issue or a Docker-free test fails in either plugin repository + docker info >/dev/null 2>&1 && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 ./... && go -C ../sm-pgstripe-plugin test -count=1 ./... && bash scripts/check-phase18.1.sh plugins + non-zero exit: no Docker daemon (record NOT RUN), any Postgres test of either repository fails (TestServiceAPI's sync subtest, TestHostSubscriptionFlow/sync_subscription, TestFreshStateSmoke/sync_keeps_plan_switch among them), or the phase gate's plugins stage reports a required name failed, skipped or missing + + + - `grep -n "type SubscriptionRetriever interface" ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go` prints one line. + - `grep -n "SubscriptionRetriever\|gatewayFields\|WithSessionLock" ../sm-paymentgatewaysubscriptions-plugin/classes/service.go` prints at least three lines. + - `sed -n '/func (s \*Service) SyncSubscription/,/^}/p' ../sm-paymentgatewaysubscriptions-plugin/classes/service.go | grep -c "Select("` prints at least 1. + - `grep -n "contracts.SubscriptionRetriever" ../sm-pgstripe-plugin/subscriptions/gateway.go` prints one line. + - `grep -n "sync_keeps_plan_switch" ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go` prints one line. + - `grep -c "D-21 amendment" .planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md` prints at least 1. + - `grep -n "18.1-07" .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md` prints at least one line. + - `git -C ../sm-paymentgatewaysubscriptions-plugin status --porcelain -- classes contracts core_smoke_test.go README.md; git -C ../sm-pgstripe-plugin status --porcelain -- subscriptions` prints nothing (every file of this plan is committed). + + A reconcile sync never overwrites a newer plan, subscriber or customer id, picks up a plan change made in the portal, and the D-21 ordering amendment is recorded where readers look. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Stripe webhook to the River job | signed events arrive out of order and are retried concurrently | +| job to Stripe API | the job now reads the authoritative subscription before writing | +| reconcile caller to the subscriptions row | a sync must not overwrite state written by the webhook job | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-18.1-35 | Tampering | pgstripe `work` / pgsub `Apply`: an older event snapshot overwrites newer state (CR-01) | high | mitigate | created and updated apply `RetrieveSubscription`'s current state; `WithSessionLock` on `SubscriptionLockKey` serialises jobs of one subscription; the claim-first transaction stays (Task 1) | +| T-18.1-36 | Elevation of Privilege | `ApplyDeleted` on an unknown row commits the marker, a later created grants access (CR-01 sequence 2) | high | mitigate | `ErrSubscriptionNotStored` rolls the marker back and River retries; the retried created event stores Stripe's canceled state (Task 2) | +| T-18.1-37 | Repudiation | a billed subscription is never stored after a reprice or code change (WR-04) | medium | mitigate | plan resolution by price, then product, then plan_code; an unresolved plan still fails loudly with the ids (Task 2) | +| T-18.1-38 | Tampering | `SyncSubscription` lost update (WR-06) | medium | mitigate | row re-read FOR UPDATE, `Select` of the gateway-owned columns only, same subscription lock as the job (Task 3) | +| T-18.1-39 | Denial of Service | a leaked session advisory lock or a slow Stripe retrieve stalls jobs of one subscription | low | mitigate | the lock is keyed per subscription, released in a deferred unlock that ignores cancellation and by connection close; a failed retrieve returns the error so River retries with backoff | +| T-18.1-40 | Information Disclosure | job arguments gain `product_id` and `plan_code` | low | accept | ids and a plan code only, no personal data; `TestJobArgsMinimal`'s fixed key list is extended and still refuses e-mail, name and address keys | +| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed by this plan; GORM `Connection` and Postgres advisory locks are already available | + + + +- Task 1: vet and -short in both plugin repositories; on a Docker host TestFreshStateSmoke, TestHostSubscriptionFlow, TestSubscriptionJob, TestStripeSubscriptionReliability and TestSubscriptionWebhookSmoke pass; pgsub links no stripe-go. +- Task 2: vet and -short in both repositories (TestJobArgsMinimal with the two new keys); the full pgsub Postgres suite and the pgstripe ordering, host, job and reliability tests pass. +- Task 3: the full Postgres suites of both repositories and the gate's plugins stage pass; both trees are clean. + + + +- CR-01: a stale or concurrently retried subscription event cannot regress the stored state, a deletion before creation is retried, and no spurious reactivated fires (TestFreshStateSmoke on Postgres; full coverage in plan 09). +- WR-04: a subscription on an archived price or a renamed plan is stored on its plan. +- WR-06: SyncSubscription writes only gateway-owned columns of a locked fresh row and refreshes the plan. +- D-21's ordering clause is recorded as superseded; nothing else about D-21 changes; no public route, event, table or response shape changes. + + + +Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-07-SUMMARY.md` when done, with the commits per repository, every adapted existing assertion (file, test, old and new expectation), the smoke results (or NOT RUN with the reason) and the D-21 amendment text. + diff --git a/.planning/phases/18.1-payment-gateway-subscriptions/18.1-08-PLAN.md b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-08-PLAN.md new file mode 100644 index 0000000..4801494 --- /dev/null +++ b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-08-PLAN.md @@ -0,0 +1,414 @@ +--- +phase: 18.1-payment-gateway-subscriptions +plan: 08 +type: execute +wave: 2 +depends_on: ["18.1-07"] +files_modified: + - ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go + - ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go + - ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go + - ../sm-paymentgatewaysubscriptions-plugin/api/json.go + - ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go + - ../sm-paymentgatewaysubscriptions-plugin/classes/service.go + - ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go + - ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go + - ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go + - ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go + - ../sm-paymentgatewaysubscriptions-plugin/README.md + - ../sm-pgstripe-plugin/subscriptions/gateway.go + - ../sm-pgstripe-plugin/subscriptions/gateway_test.go + - ../sm-pgstripe-plugin/subscriptions/README.md + - modules/pact/capabilities.go + - modules/cabana/crud.go + - modules/pact/README.md + - modules/cabana/README.md + - docs/backend/admin-controllers.md + - ../vue-paymentgateway-layer/app/utils/subscribe.ts + - ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue + - ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue + - ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue + - ../vue-paymentgateway-layer/i18n/locales/en.json + - ../vue-paymentgateway-layer/i18n/locales/pl.json + - ../vue-paymentgateway-layer/README.md + - .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md +autonomous: true +gap_closure: true +requirements: [SC-3, D-06, D-13, D-14, D-15, CR-02, WR-01, WR-02, WR-03, WR-07] +estimate: + tokens: 160000 + raw_tokens: 160000 + tasks: 4 + confidence: low +must_haves: + truths: + - "CR-02 (customer API): POST /_paymentgatewaysubscriptions/api/v1/subscription/checkout answers 422 {\"error\": \"Validation failed\", \"errors\": {\"currency\": [...]}} without calling the gateway when the plan has no Stripe price of its own in the requested currency (SubscriptionPlan.CheckoutPriceID is empty): a plan whose stripe_prices map has entries uses only that map, and a plan without a synced map uses stripe_price_id only in the plan's own currency. No currency is ever billed at another currency's price." + - "CR-02 (gateway): the Stripe gateway's CreateCheckoutSession uses CheckoutPriceID and answers ErrNoPriceForCurrency without calling Stripe for a currency without its own price, so Service.Checkout callers outside the customer API get the same refusal; the zero-decimal yen is no longer in SupportsCurrency (the core shows minor units divided by 100), and SubscriptionPlan.StripePriceIDFor keeps PHP's lenient fallback for other readers." + - "WR-02: the Stripe customer create carries an idempotency key built from the subscriber alias and id, a hash of the parameters and a 10-minute window, and the built-in user subscriber stores stripe_customer_id only when none is stored (UPDATE ... WHERE stripe_customer_id IS NULL OR = ''), re-reading and adopting the stored id otherwise; Service.Checkout then uses the stored id, and the write ignores request cancellation, so a customer Stripe created is never lost or overwritten by a concurrent checkout." + - "WR-03: checkout is refused with 409 Already subscribed while the user has any subscription whose status is not canceled or incomplete_expired (active, trialing, past_due, unpaid, incomplete, paused); past_due and unpaid users get the layer's existing billing-portal button on that 409. Service.CheckoutGuarded re-checks under a per-subscriber session advisory lock (classes.SubscriberLockKey) around the re-resolved subscriber, the customer and the session create, and the session create carries an idempotency key per subscriber, plan, currency and parameters in a 10-minute window, so parallel requests from two tabs yield at most one Checkout session per plan and currency." + - "WR-01: the admin bulk action sync_to_stripe is a framework-detached bulk action (pact.AdminBulkAction.Detached, a new additive framework field): cabana loads the selected plans through the list scope without a transaction or row locks, and each plan's Stripe calls run outside any transaction with its ids saved right after them in a short write that ignores request cancellation; the toolbar sync_all_to_stripe opens no transaction and counts a failed save as a failure (no discarded transaction result); the per-plan result toast is unchanged." + - "WR-07: after a redirect to Stripe Checkout or the billing portal, a back/forward-cache restore (pageshow with persisted true) re-enables PgSubscribeButton, PgManageSubscriptionButton and PgMySubscription's manage and cancel actions (flow.restore()), and a navigation that throws re-enables the button with the generic error; each component removes its pageshow listener on unmount." + - "Compatibility: routes, event names, tables, config keys and the customer API response shapes are unchanged (the new refusals reuse the existing 409 and 422 bodies and are documented); contracts.Gateway keeps its methods; Service.Checkout keeps its behaviour (CheckoutGuarded is new); non-detached bulk actions in every plugin behave exactly as before; pgsub links no stripe-go (D-06); every commit keeps go vet and go test ./... green in summercms.go and both plugin repositories and pnpm test green in the layer; nothing is pushed or tagged (release is plan 09)." + artifacts: + - path: "../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go" + provides: "strict per-currency gateway price" + contains: "func (p SubscriptionPlan) CheckoutPriceID" + - path: "../sm-paymentgatewaysubscriptions-plugin/classes/service.go" + provides: "CheckoutGuarded, Open, ErrAlreadySubscribed, cancellation-proof id saves" + contains: "CheckoutGuarded" + - path: "../sm-pgstripe-plugin/subscriptions/gateway.go" + provides: "strict price, idempotency keys, Gateway.Now" + contains: "SetIdempotencyKey" + - path: "modules/pact/capabilities.go" + provides: "the Detached bulk action field" + contains: "Detached" + - path: "../vue-paymentgateway-layer/app/utils/subscribe.ts" + provides: "watchPageRestore and the flows' restore" + contains: "pageshow" + key_links: + - from: "../sm-paymentgatewaysubscriptions-plugin/api/subscription.go" + to: "../sm-paymentgatewaysubscriptions-plugin/classes/service.go" + via: "Checkout handler calls d.svc.CheckoutGuarded and maps ErrAlreadySubscribed to 409" + pattern: "CheckoutGuarded" + - from: "../sm-pgstripe-plugin/subscriptions/gateway.go" + to: "../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go" + via: "CreateCheckoutSession reads plan.CheckoutPriceID(currency)" + pattern: "CheckoutPriceID" + - from: "../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go" + to: "modules/cabana/crud.go" + via: "sync_to_stripe declares Detached: true; BulkAction runs it outside the transaction" + pattern: "Detached" + - from: "../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue" + to: "../vue-paymentgateway-layer/app/utils/subscribe.ts" + via: "onMounted watchPageRestore(window, flow.restore), removed onBeforeUnmount" + pattern: "watchPageRestore" + prohibitions: + - statement: "No customer API response body, status for an existing case, route or event name changes; the new refusals reuse the existing 409 and 422 bodies." + status: unresolved + verification: judgment + - statement: "Framework code and docs name no consuming application, payment provider or subscription concept in the Detached addition." + status: unresolved + verification: judgment + - statement: "Another session's uncommitted edits in summercms.go are never staged, stashed or overwritten." + status: unresolved + verification: judgment +--- + +## Phase Goal + +ROADMAP Goal (not in user-story format; no story is invented, as in plans 01 to 07): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP. + +This plan's slice (gap closure 2 of 3, wave 2, after 18.1-07 because both change `classes/service.go`, `classes/lock.go` and the Stripe gateway): checkout, admin sync and layer findings of `18.1-REVIEW.md`: CR-02 (cross-currency charge), WR-02 (duplicate Stripe customers), WR-03 (double subscriptions), WR-01 (plan sync inside the bulk transaction) and WR-07 (buttons stuck after Back). The user decided on 2026-10-14 to fix them. + +## Decisions taken in this plan (Claude's discretion) + +- **WR-01 needs a framework addition.** cabana runs every bulk action inside a transaction that holds `FOR UPDATE` locks on the selected rows. Saving a plan's Stripe ids "in a short transaction of its own" from inside `Run` would wait on that lock until it times out. A Stripe call made outside the lock is therefore impossible from plugin code. The plan adds one opt-in field, `pact.AdminBulkAction.Detached` (`json:"-"`, so neither the list schema nor the OpenAPI changes). It ships in framework tag v0.1.27 at plan 09's release. This is the "framework feature is missing" case of CLAUDE.md. The alternative was rejected: keep the bulk sync inside the transaction and only make product creation self-healing. That still holds the lock and the open transaction across Stripe calls, which is exactly what the review flags. Reversibility: costly, flagged on Task 3. +- **The yen is dropped rather than special-cased.** `money.FromMinor` and the customer API's `amount_minor` assume two decimals. Supporting zero-decimal currencies would change the Money JSON contract. Refusing the yen at checkout (422) is the smaller, non-breaking change, and the README records it as a deviation from PHP's list. +- **CheckoutPriceID is new, StripePriceIDFor stays.** `StripePriceIDFor` is PHP's `getStripePriceId` (lenient fallback), and other readers may rely on it. Checkouts use the strict method. A `stripe_prices` map with at least one entry decides alone. An empty or missing map means the plan was never synced, so a hand-entered `stripe_price_id` serves only the plan's own currency. +- **WR-03 serialisation without a transaction across Stripe.** Local rows appear only after the webhook, so a database lock alone cannot stop two sessions opened before payment. Three measures together cover the race: + - a session-level advisory lock per subscriber, the helper from plan 07, which serialises the check, the customer and the session; + - a Stripe idempotency key over the subscriber, plan, currency and parameters, which returns the same session to a parallel or repeated request; + - the non-terminal status guard, which refuses once a subscription exists. + `Service.Checkout` keeps PHP's unguarded behaviour for app routes that do their own check; `CheckoutGuarded` is the customer-facing entry point. +- **Idempotency keys have a 10-minute window and a parameter hash.** Stripe replays a stored response, including a 500, for 24 hours. The window bounds a replayed failure, and the hash avoids an `idempotency_error` when an application's metadata differs between calls. +- **"Send past_due and unpaid users to the portal"** is met by the existing 409 and the layer's `PgManageSubscriptionButton`, which shows on that 409. The copy loses the word "active" so it fits those users too. + +## Artifacts this phase produces (this plan) + +- `../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go`: `(SubscriptionPlan) CheckoutPriceID(currency string) string`. +- `../sm-paymentgatewaysubscriptions-plugin/models/subscription.go`: `TerminalStatuses`, `ScopeOpen(db *gorm.DB) *gorm.DB`. +- `../sm-paymentgatewaysubscriptions-plugin/classes/lock.go`: `SubscriberLockKey(alias string, id uint) string`. +- `../sm-paymentgatewaysubscriptions-plugin/classes/service.go`: `ErrAlreadySubscribed`, `(*Service).Open`, `(*Service).CheckoutGuarded`; `ensureCustomer` stores through a cancellation-free context and uses the stored winner; `SyncPlan` with a nil tx saves through a cancellation-free context. +- `../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go`: first-write-wins `SetCustomerID` with re-read. +- `../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go`: `SetCustomerID` doc (an implementation may keep a stored id). +- `../sm-paymentgatewaysubscriptions-plugin/api/subscription.go` and `api/json.go`: the 422 gateway-price gate, the 409 on any open subscription through `Open` and `CheckoutGuarded`, the updated contract comment. +- `../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go`: `sync_to_stripe` with `Detached: true`; `syncOne(ctx, svc, plan, tally)` saves through `SyncPlan(ctx, nil, plan)`; `runSyncAll` without a transaction wrapper. +- `../sm-pgstripe-plugin/subscriptions/gateway.go`: `CreateCheckoutSession` on `CheckoutPriceID`, the yen removed from `supportedCurrencies`, field `Gateway.Now func() time.Time`, unexported `idempotencyWindow` and `idempotencyKey(kind string, parts ...string) string`, keys on the customer create and the session create. +- `modules/pact/capabilities.go`: field `AdminBulkAction.Detached bool` (`json:"-"`). `modules/cabana/crud.go`: the detached branch of `CRUDService.BulkAction`, unexported `loadScoped` with `lockScoped` kept as its locking wrapper. +- `../vue-paymentgateway-layer/app/utils/subscribe.ts`: `PageRestoreTarget`, `watchPageRestore(target, handler): () => void`, `SubscribeFlow.restore()`, `ManageFlow.restore()`, a guarded `location.assign`. +- Smoke subtests: `TestCustomerAPISmoke/currency_without_gateway_price_is_422`, `TestCustomerAPISmoke/past_due_is_409`. +- Adapted tests: `gateway_test.go` subtests "the checkout currency picks the price" (no fallback across currencies) and "supported currencies" (the yen moves to the unsupported list); the customer API smoke's `pro` plan gets `stripe_prices` for the currencies it checks out in. +- Docs: pgsub README, pgstripe subscriptions README, `modules/pact/README.md`, `modules/cabana/README.md`, `docs/backend/admin-controllers.md`, the layer README, `COVERAGE.md`. + + +Close CR-02, WR-02, WR-03, WR-01 and WR-07: +- a checkout charges only the price of the currency it shows; +- concurrent or repeated checkouts create neither a second Stripe customer nor a second session or subscription; +- any open subscription blocks a new checkout; +- the admin plan sync never calls Stripe inside a transaction or loses the ids Stripe created; +- the layer's buttons recover after the browser's Back button. + +Purpose: these money-relevant and user-facing defects must be fixed before any application cuts over to this plugin. +Output: commits in summercms.go (framework Detached, docs), sm-paymentgatewaysubscriptions-plugin, sm-pgstripe-plugin and vue-paymentgateway-layer, plus a planning-docs commit (COVERAGE). New unit-test coverage is plan 09. This plan carries two smoke subtests and the adaptations of existing tests whose expectations the fixes change. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-CONTEXT.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-04-SUMMARY.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-05-SUMMARY.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-07-SUMMARY.md + +Repositories: summercms.go (framework), ../sm-paymentgatewaysubscriptions-plugin (pgsub), ../sm-pgstripe-plugin (pgstripe), ../vue-paymentgateway-layer (layer). The plugins `replace` summercms with ../summercms.go and pgstripe replaces pgsub, so local builds see uncommitted siblings. Commit order inside a task: framework, then pgsub, then pgstripe, then the layer; one logical change per commit; stage by explicit path; no co-author tags; never push or tag. Several sessions work in summercms.go: before touching a framework file, `git status --porcelain -- ` must show no foreign edit; if it does, stop and report instead of staging, stashing or overwriting. + + +- pgsub customer API: `Checkout` api/subscription.go:173-241 (order: body, plan_code, 409 via `d.svc.Active` :192, plan 404, free 422, gateway, currency 422 via `priced` and `SupportsCurrency` :221, URLs, `d.svc.Subscriber` :229, `d.svc.Checkout` :234, `writeGatewayError` maps to 502 or 503); `priced` :245; error bodies api/errors.go:15-30 (`errAlreadySubscribed` "Already subscribed", `msgCurrencyInvalid`); the contract comment api/json.go:1-33. +- `models.SubscriptionPlan.StripePriceIDFor` :109-119 (lenient), `PriceCentsFor` :97; `models.ScopeActive`, `ForSubscriber`, status consts models/subscription.go:14-28, :100-119. +- `classes.Service.Checkout` service.go:263-279, `ensureCustomer` :392-412, `SyncPlan` :362-388 (nil tx falls back to s.db), `Active` :174, `Latest` :105, `Subscriber` :222; `WithSessionLock` and `SubscriptionLockKey` in classes/lock.go (plan 07). +- `userSubscriber.SetCustomerID` classes/user_subscribable.go:85-98 (unconditional UPDATE today); `contracts.Subscriber` contracts/subscriber.go. +- pgstripe gateway: `GetOrCreateCustomer` gateway.go:76-109, `CreateCheckoutSession` :120-181 (`StripePriceIDFor` at :128, metadata map :144-158), `supportedCurrencies` :32, `ErrNoPriceForCurrency` :25; stripe-go v87 `(*stripe.Params).SetIdempotencyKey(string)` (params.go:310) on `CustomerCreateParams` and `CheckoutSessionCreateParams`. +- Admin sync: controllers/subscriptionplans.go `AdminBulkActions` :48, `AdminActions` :62, `syncOne` :82, `runSyncSelected` :130-153, `runSyncAll` :155-179. +- Framework: `pact.AdminBulkAction` modules/pact/capabilities.go:285-291; `CRUDService.BulkAction` modules/cabana/crud.go:349-391, `lockScoped` :939-971, `actionFailure` :396, `bulkActionOf` modules/cabana/actions.go:173; bulk docs docs/backend/admin-controllers.md:439-447; pact README table :132-135; cabana README bullet :32; bulk tests modules/cabana/phase121_bulk_test.go (13 `TestBulkAction*` functions). +- Layer: `createSubscribeFlow` and `createManageFlow` app/utils/subscribe.ts:70-149 (`state.redirecting = true` then `deps.location.assign`); the three components build their flows with `location: { assign: (url) => window.location.assign(url) }`; `already_subscribed` copy i18n/locales/en.json:147 and pl.json:147; README § Subscriptions :125-179. + + + + + + + Task 1: Tracer: a checkout in a currency without its own Stripe price is refused end to end (customer API 422, gateway ErrNoPriceForCurrency), never billed at another currency's price (CR-02) + A stricter price choice behind unchanged routes and bodies; reverting restores the fallback. + ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go, ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/api/json.go, ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/gateway_test.go, ../sm-pgstripe-plugin/subscriptions/README.md + ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go, ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/api/json.go (contract comment, planPrices), ../sm-paymentgatewaysubscriptions-plugin/api/errors.go, ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go (plan setup :158-172, checkout_checks :226, checkout :246), ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go (fakeGateway.SupportsCurrency :246, CreateCheckoutSession :171), ../sm-pgstripe-plugin/subscriptions/gateway.go (CreateCheckoutSession, supportedCurrencies, SupportsCurrency), ../sm-pgstripe-plugin/subscriptions/gateway_test.go (subtests "the checkout currency picks the price" :122 and "supported currencies" :241), ../sm-pgstripe-plugin/subscriptions/sync_plan.go (which currencies a sync prices), both READMEs (Customer API table, Features: Checkout and Currencies, Deviations), .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (CR-02 in full) + Implements CR-02 on the D-13 customer API and the D-15 plan-sync price map; response shapes stay as D-13's confirmed contract. + +pgsub (commit first): models/subscription_plan.go adds `CheckoutPriceID(currency string) string`. The currency is trimmed and lower-cased. When `StripePrices` is valid with at least one entry, it answers that map's entry for the currency or "" (no fallback). Otherwise it answers `*StripePriceID` only when the currency equals the plan's `Currency` case-insensitively and the id is non-empty, else "". The doc comment of `StripePriceIDFor` says it is PHP's lenient getStripePriceId and that checkouts use CheckoutPriceID. In api/subscription.go `Checkout`, after the existing `priced` and `SupportsCurrency` check and before the URL check, `plan.CheckoutPriceID(currency) == ""` answers the same 422 `{"currency": [msgCurrencyInvalid]}` without calling the gateway. Update the handler's doc comment and the api/json.go contract comment: a 422 also covers a currency the plan has no gateway price in, and no currency is billed at another currency's price. customer_api_smoke_test.go: +- the `pro` plan gets `stripe_prices` entries for pln and eur, so the existing `checkout` subtest (EUR) still passes; +- a new subtest `currency_without_gateway_price_is_422` creates a plan with currency_prices pln and gbp and stripe_prices pln and usd, then asserts that a gbp checkout answers 422 with the currency error and makes no gateway call, and that a pln checkout answers 200 with the gateway called in pln. + +README (pgsub): Customer API table row of checkout (422 text); API reference adds `CheckoutPriceID`. + +pgstripe (second commit), gateway.go: +- `CreateCheckoutSession` takes its price from `plan.CheckoutPriceID(currency)`; "" stays `ErrNoPriceForCurrency` (wrapping, naming plan and currency, no Stripe call). Its comments describe the strict rule by concept and do not name the lenient model method. Update the sentinel's doc: no price of its own in the currency, no fallback across currencies. +- Drop the zero-decimal Japanese yen code from `supportedCurrencies`. `SupportsCurrency`'s doc says the list is PHP's minus the yen, because the core's money display assumes two decimals. + +Adapt gateway_test.go in the same commit: +- "the checkout currency picks the price": a currency the gold plan's map lacks now expects ErrNoPriceForCurrency and no Stripe call, instead of the default price id; +- "supported currencies": the yen moves to the refused list. + +Run the subscriptions suite and adapt any other assertion that relied on the fallback, naming each in the SUMMARY. README (subscriptions): +- Features "Checkout" names CheckoutPriceID and the missing fallback; +- the "Currencies" line drops the yen; +- "Deviations from PHP" gains two lines: no cross-currency fallback (PHP getStripePriceId fell back to stripe_price_id), and the yen is not supported. + + go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... + non-zero exit: vet reports an issue or a Docker-free test fails in either plugin repository + docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestCustomerAPISmoke|TestCustomerAPIAuthz|TestServiceAPI)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 3 && OUT2=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestSubscriptionGateway|TestHostSubscriptionFlow)$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT2" | grep -c '^--- PASS: Test' | grep -qx 2 + non-zero exit: no Docker daemon (record NOT RUN), fewer than three pgsub top-level PASS lines (the 422 gate refused a priced currency or let an unpriced one through), or fewer than two pgstripe PASS lines (the gateway fell back across currencies or the host checkout broke) + + + - `grep -n "func (p SubscriptionPlan) CheckoutPriceID" ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go` prints one line. + - `grep -n "CheckoutPriceID" ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go ../sm-pgstripe-plugin/subscriptions/gateway.go` prints at least two lines. + - `sed -n '/func (g \*Gateway) CreateCheckoutSession/,/^}/p' ../sm-pgstripe-plugin/subscriptions/gateway.go | grep -c "StripePriceIDFor"` prints 0. + - `grep -n 'supportedCurrencies = ' ../sm-pgstripe-plugin/subscriptions/gateway.go | grep -c jpy` prints 0. + - `grep -n "currency_without_gateway_price_is_422" ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go` prints one line. + - `test "$(go -C ../sm-paymentgatewaysubscriptions-plugin list -deps ./... | grep -c stripe-go)" = 0` succeeds (D-06). + + A request for a currency the plan was never priced in at Stripe is refused before any Stripe call, from the customer API through the service to the gateway, and a priced currency checks out on its own price. + + + + Task 2: One Stripe customer and one checkout per subscriber: first-write-wins customer id with idempotency keys (WR-02), and a guarded checkout refused for any open subscription under a per-subscriber lock (WR-03) + New exported service API (`CheckoutGuarded`, `Open`, `ErrAlreadySubscribed`, `ScopeOpen`, `TerminalStatuses`) and a relaxed `Subscriber.SetCustomerID` contract (first write may win): additive, but published plugin API once tagged. + ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go, ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/api/json.go, ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, ../sm-pgstripe-plugin/subscriptions/gateway.go, ../sm-pgstripe-plugin/subscriptions/README.md, .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md + ../sm-paymentgatewaysubscriptions-plugin/classes/service.go (Checkout, ensureCustomer, Active, Latest, Subscriber), ../sm-paymentgatewaysubscriptions-plugin/classes/lock.go (plan 07), ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go, ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go, ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go (statuses, scopes), ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go (Checkout after Task 1), ../sm-paymentgatewaysubscriptions-plugin/user_subscribable_test.go (whether it asserts an overwrite), ../sm-paymentgatewaysubscriptions-plugin/service_api_test.go (checkout subtests, the open-transaction probe), ../sm-pgstripe-plugin/subscriptions/gateway.go (GetOrCreateCustomer, CreateCheckoutSession), ../sm-pgstripe-plugin/subscriptions/stripefake_test.go (record: whether headers are kept), .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md, .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (WR-02, WR-03 in full) + Implements WR-02 and WR-03 for the D-13 customer API; D-08/D-09 (the user subscriber writes only users.stripe_customer_id) hold. + +pgsub (commit first): +- models/subscription.go adds `TerminalStatuses` (canceled, incomplete_expired) and `ScopeOpen` (stripe_status NOT IN TerminalStatuses). +- classes/lock.go adds `SubscriberLockKey(alias, id)` built from the same prefix as SubscriptionLockKey, with "subscriber:", the alias, ":" and the decimal id. +- classes/service.go adds `ErrAlreadySubscribed`, `Open(ctx, alias, id)` and `CheckoutGuarded(ctx, sub, plan, successURL, cancelURL, o)`: + - `Open` returns the latest subscription with ScopeOpen and its Plan loaded, ordered like Latest, or nil. + - `CheckoutGuarded` runs inside `WithSessionLock(ctx, s.db, SubscriberLockKey(sub.MorphAlias(), sub.Key()), ...)`. Inside the lock it calls `Open`; an open subscription returns an error wrapping ErrAlreadySubscribed that names the row id and status. It re-resolves the subscriber with `s.Subscriber` (so a customer id stored by a concurrent request is seen), then delegates to `s.Checkout`. No transaction is held across the gateway calls; the lock is session-level. + - `Checkout` itself is unchanged (PHP createCheckoutSession has no guard). Its doc points customer-facing callers to CheckoutGuarded. +- `ensureCustomer`: + - stores the id in its short transaction as today, now opened with `context.WithoutCancel(ctx)`, so a customer Stripe already created is recorded even when the request was cancelled; + - after the store, reads `sub.CustomerID()`. When it differs from the created id, it logs at Warn that a concurrent checkout stored another customer, with both ids (ids only), and continues with the stored one. +- classes/user_subscribable.go `SetCustomerID`: + - runs `UPDATE users SET stripe_customer_id = ? WHERE id = ? AND (stripe_customer_id IS NULL OR stripe_customer_id = '')`; + - with one row affected it remembers id; + - with none it re-reads stripe_customer_id of that id with a raw query (no soft-delete filter): no row is ErrSubscriberNotFound, a stored non-empty id is remembered and nil returned, an empty one is an error. + - It still writes no other column. +- contracts/subscriber.go: the `SetCustomerID` doc says an implementation may keep an id that is already stored (first write wins), and CustomerID then answers the stored id. +- api/subscription.go `Checkout` answers 409 errAlreadySubscribed in two places: + - its early check (same position) uses `d.svc.Open` instead of `d.svc.Active`; + - after the subscriber is resolved it calls `d.svc.CheckoutGuarded`, and an error wrapping `classes.ErrAlreadySubscribed` answers 409. + - Other errors keep `writeGatewayError`. +- api/json.go contract comment: 409 for any subscription that is not canceled or incomplete_expired (a past_due or unpaid user manages it in the billing portal). +- customer_api_smoke_test.go adds subtest `past_due_is_409`: a past_due row of the user answers 409 "Already subscribed" and the gateway is not called. Adapt any existing assertion that expects an overwrite of a stored customer id, naming it in the SUMMARY. + +README (pgsub): +- API reference lists Open, CheckoutGuarded, ErrAlreadySubscribed, ScopeOpen, TerminalStatuses and SubscriberLockKey; +- the Customer API table's 409 row; +- Usage: customer-facing checkouts call CheckoutGuarded; +- the built-in user subscriber keeps the first stored customer id. + +pgstripe (second commit), gateway.go: +- adds the field `Now func() time.Time` (nil means time.Now), an unexported `idempotencyWindow` of 10 minutes, and an unexported `idempotencyKey(kind string, parts ...string) string`. The key is "pgsub-", the kind, "-", the decimal window number (Unix seconds of Now divided by the window), "-", and the first 32 hex characters of the SHA-256 (crypto/sha256, encoding/hex) of the parts joined with a NUL byte. +- `GetOrCreateCustomer` sets the key on its CustomerCreateParams: kind "customer"; parts are the alias, the id, the e-mail, the name and the sorted metadata pairs. +- `CreateCheckoutSession` sets the key on its CheckoutSessionCreateParams: kind "checkout"; parts are the alias, the id, the customer id, the price id, the success and cancel URLs, and the sorted metadata pairs. +- Keys are never logged. + +README (subscriptions): the Customer and Checkout features describe the keys and the window; API reference lists `Gateway.Now`. + +Planning docs (summercms.go, own commit): COVERAGE.md rows "customers.create with subscriber metadata" and "checkout.sessions.create (mode subscription)" keep INTEGRATE; their reasons name the idempotency key (WR-02 and WR-03, plan 18.1-08). + + go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... + non-zero exit: vet reports an issue or a Docker-free test fails in either plugin repository + docker info >/dev/null 2>&1 && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 ./... && OUT=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestSubscriptionGateway|TestHostSubscriptionFlow|TestFreshStateSmoke)$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 3 + non-zero exit: no Docker daemon (record NOT RUN), a pgsub Postgres test fails (TestCustomerAPISmoke/past_due_is_409, TestUserSubscribable, TestServiceAPI's checkout subtests among them), or fewer than three pgstripe top-level PASS lines + + + - `grep -n "func (s \*Service) CheckoutGuarded\|func (s \*Service) Open\|ErrAlreadySubscribed =" ../sm-paymentgatewaysubscriptions-plugin/classes/service.go` prints three lines. + - `grep -n "WithoutCancel" ../sm-paymentgatewaysubscriptions-plugin/classes/service.go` prints at least one line. + - `grep -n "stripe_customer_id IS NULL" ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go` prints one line. + - `grep -n "CheckoutGuarded\|svc.Open" ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go` prints at least two lines. + - `grep -c "SetIdempotencyKey" ../sm-pgstripe-plugin/subscriptions/gateway.go` prints at least 2. + - `grep -n "past_due_is_409" ../sm-paymentgatewaysubscriptions-plugin/customer_api_smoke_test.go` prints one line. + - `grep -n "18.1-08" .planning/phases/18.1-payment-gateway-subscriptions/COVERAGE.md` prints at least two lines. + + A double click, a second tab or a client retry yields one Stripe customer and at most one Checkout session per plan and currency, and a user with any open subscription is sent to manage it instead of buying a second one. + + + + Task 3: Plan sync never holds a transaction or row lock across Stripe calls and never loses the ids Stripe created: framework detached bulk actions and the reworked sync actions (WR-01) + Adds `pact.AdminBulkAction.Detached`, a framework API field that becomes part of tag v0.1.27 at plan 09's release; additive and opt-in, but removing it later would break any plugin that sets it. + `git status --porcelain -- modules/pact/capabilities.go modules/cabana/crud.go modules/pact/README.md modules/cabana/README.md docs/backend/admin-controllers.md` prints nothing in summercms.go (no other session has uncommitted edits in these files). + modules/pact/capabilities.go, modules/cabana/crud.go, modules/pact/README.md, modules/cabana/README.md, docs/backend/admin-controllers.md, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go, ../sm-paymentgatewaysubscriptions-plugin/classes/service.go, ../sm-paymentgatewaysubscriptions-plugin/README.md + modules/pact/capabilities.go (AdminBulkAction and its doc :274-316), modules/cabana/crud.go (BulkAction :349-391, lockScoped :939-971, the transaction helper :133), modules/cabana/actions.go (bulkActionOf), modules/cabana/tx_context.go (TxFromContext), modules/cabana/phase121_bulk_test.go (what the 13 bulk tests pin), docs/backend/admin-controllers.md (:430-450), modules/pact/README.md (:30, :132-135), modules/cabana/README.md (:32), ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go (whole file), ../sm-paymentgatewaysubscriptions-plugin/classes/service.go (SyncPlan), ../sm-paymentgatewaysubscriptions-plugin/admin_actions_test.go (what TestAdminSyncActions asserts), ../sm-paymentgatewaysubscriptions-plugin/README.md (Admin section), .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (WR-01 in full) + Implements WR-01 for the D-15 plan sync. The framework addition is the only way to keep Stripe calls out of cabana's bulk transaction (see "Decisions taken in this plan"). + +Framework (summercms.go, commit first, after the precondition check): +- modules/pact/capabilities.go: `AdminBulkAction` gains `Detached bool` tagged `json:"-"`. Its doc: Run then runs outside the framework transaction; the selected records are loaded through the list scope without row locks; the context carries no write transaction; Run opens the short transactions it needs; an action that calls an external service sets it so that no transaction or row lock is held across the call; what such an action saved before a later failure stays saved. The type's doc paragraph mentions the field. +- modules/cabana/crud.go `BulkAction`: after the existing name, id and model checks, an action with Detached set takes a new branch that opens no transaction. It loads the rows with a new unexported `loadScoped(ctx, db, cc, proto, ids, lock bool)`; `lockScoped` becomes a call of it with lock true, so the locked path is byte-identical. The branch loads on `s.DB` with lock false and applies the same rules: + - no row: affected 0 without running the action; + - fewer rows than ids: the existing partial-selection 409; + - Run is called with the locale context but no write transaction; + - an error goes through `actionFailure`; + - the message is translated with `translateKey` as on the locked path. + The non-detached path is unchanged. +- Docs in the same commit, naming no application, provider or subscription: + - docs/backend/admin-controllers.md: the bulk route list gains a bullet for `Detached: true` (loaded through the same scope without row locks, `cabana.TxFromContext` reports no transaction, Run opens its own short transactions, the use case of external calls, earlier saves stay on a later failure); + - modules/pact/README.md: the `pact.AdminBulkAction` table row mentions the field; + - modules/cabana/README.md: the bulk actions bullet mentions the detached variant. + +pgsub (second commit): +- controllers/subscriptionplans.go: + - The sync_to_stripe bulk action sets `Detached: true`. + - `runSyncSelected` no longer reads the framework transaction from the context. It type-checks the records as today and syncs each plan. + - `syncOne` drops its transaction parameter and calls `svc.SyncPlan(ctx, nil, plan)`. + - `runSyncAll` iterates the active plans and calls `syncOne` directly, with no transaction wrapper around it. A failed save comes back from SyncPlan as an error and becomes a failure line, never a synced count. + - Update the doc comments of AdminBulkActions, AdminActions, syncOne and both run functions: no transaction is held across the Stripe calls, and each plan's ids are saved right after its calls. + - Remove imports that become unused. +- classes/service.go `SyncPlan`: a nil tx saves through `s.db` with `context.WithoutCancel(ctx)`, so the ids Stripe created are recorded although the admin's request was cancelled. A non-nil tx keeps today's behaviour. The doc says request paths pass nil, and the save stays the `Select` of stripe_product_id, stripe_prices, stripe_price_id and updated_at. +- README (pgsub) Admin section: both sync actions run outside any transaction, and each plan's ids are saved right after its Stripe calls. + +The plugin go.mod keeps `require summercms v0.1.26` with the replace. The v0.1.27 tag and the require bump are plan 09's release. + + go vet ./... && go test -count=1 -short ./modules/cabana ./modules/pact && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check && bash scripts/check-admin-openapi.sh --check + non-zero exit: vet, a Docker-free cabana or pact test, the docs tree or docs:build checker (an identifier or link in the new docs text that does not exist), or OpenAPI drift (the new field leaked into the schema) + docker info >/dev/null 2>&1 && OUT=$(go test -count=1 -run '^TestBulkAction' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: TestBulkAction' | grep -qx 13 && go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && OUT2=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestAdminSyncActions|TestAdminPlansSmoke|TestServiceAPI|TestSubscriptionsCoreSmoke)$' -v . 2>&1) && printf '%s\n' "$OUT2" | grep -c '^--- PASS: Test' | grep -qx 4 + non-zero exit: no Docker daemon (record NOT RUN), fewer than 13 framework TestBulkAction passes (the locked path changed), or fewer than four pgsub top-level PASS lines (a sync result message, a saved id or the service sync changed) + + + - `grep -n 'Detached ' modules/pact/capabilities.go | grep -c 'json:"-"'` prints 1. + - `grep -n "Detached" modules/cabana/crud.go` prints at least one line and `grep -n "func loadScoped" modules/cabana/crud.go` prints one line. + - `grep -n "Detached" docs/backend/admin-controllers.md modules/pact/README.md modules/cabana/README.md` prints at least three lines. + - `grep -rniE "stripe|subscription|horoskopia|queststream|quotify" modules/pact/capabilities.go modules/cabana/crud.go | grep -i detached` prints nothing. + - `grep -n "Detached:" ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go` prints one line. + - `grep -c 'TxFromContext(ctx)' ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go` prints 0 (the bulk action no longer reads the framework transaction). + - `grep -c 'lagoon.Transaction' ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go` prints 0 (neither sync action wraps the gateway calls in a transaction). + - `git status --porcelain -- modules/pact modules/cabana docs/backend` in summercms.go prints nothing after the framework commit (committed, nothing foreign staged). + + Selecting plans and clicking "Sync to Stripe" or "Sync all" reports per-plan results as before. No transaction or row lock is open while Stripe is called, and an id Stripe created is saved even if the request is cut off. + + + + Task 4: Subscribe and manage buttons recover after Back from Stripe (bfcache) or a failed navigation, and the 409 copy fits every open subscription (WR-07, WR-03 copy) + Client-side state handling and copy in the layer; reverting restores the old behaviour. + ../vue-paymentgateway-layer/app/utils/subscribe.ts, ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue, ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue, ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue, ../vue-paymentgateway-layer/i18n/locales/en.json, ../vue-paymentgateway-layer/i18n/locales/pl.json, ../vue-paymentgateway-layer/README.md + ../vue-paymentgateway-layer/app/utils/subscribe.ts (whole file), ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue, ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue, ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue (manage flow, refresh), ../vue-paymentgateway-layer/tests/subscribe.test.ts and tests/subscriptionComponents.test.ts (what the existing specs pin, including copy), ../vue-paymentgateway-layer/i18n/locales/en.json and pl.json (pg.subscriptions.error.already_subscribed), ../vue-paymentgateway-layer/README.md (§ Subscriptions), .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (WR-07) + Implements WR-07 in the D-14 layer and the copy half of WR-03. + +app/utils/subscribe.ts: +- Add an exported interface `PageRestoreTarget` with `addEventListener` and `removeEventListener` for a `pageshow` listener. Add `watchPageRestore(target, handler)`, which returns a remover: + - it registers a pageshow listener that calls handler only when the event's `persisted` is true (a back/forward-cache restore); + - the remover unregisters it; + - an undefined or null target (server rendering) returns a no-op remover. +- `SubscribeFlow` and `ManageFlow` gain `restore(): void`, which sets busy and redirecting false and error null. +- In both flows, the `deps.location.assign(url)` call moves inside a try block. A throw resets busy and redirecting and sets error to pg.subscriptions.error.generic. +- Update the header comment: the redirect keeps the button busy until the page is left, and a back/forward-cache restore re-enables it. + +Components: +- SubscribeButton.vue and ManageSubscriptionButton.vue keep a remover. `onMounted` sets it from `watchPageRestore(window, () => flow.restore())`; `onBeforeUnmount` calls it. +- MySubscription.vue does the same for its manage flow and also calls `refresh()` on a restore, so the panel shows what the portal changed. +- Update each header comment. + +Copy (WR-03): en and pl `pg.subscriptions.error.already_subscribed` drop "active" ("You already have a subscription. Manage it from your billing account." and "Masz już subskrypcję. Możesz nią zarządzać na koncie rozliczeniowym."), because past_due and unpaid users get the same 409 and the manage button. + +README § Subscriptions: describe `restore()`, `watchPageRestore` and the back/forward-cache behaviour, and that the 409 covers any open subscription. + +Run `pnpm test` and adapt only assertions that pin the old copy, naming each in the SUMMARY. Do not change package.json (the 0.2.1 version and tag are plan 09). Commit in the layer only. + + OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && bash scripts/check-phase18.1.sh layers + non-zero exit, or the TAP summary lacks "# fail 0" (a layer spec failed), or the gate's layers stage reports a required subscriptions test missing or fewer than its minimum passing + + + - `grep -n "export function watchPageRestore\|restore()" ../vue-paymentgateway-layer/app/utils/subscribe.ts` prints at least two lines and `grep -c "pageshow" ../vue-paymentgateway-layer/app/utils/subscribe.ts` prints at least 1. + - `grep -l "onBeforeUnmount" ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue | wc -l` prints 3. + - `grep -c "watchPageRestore" ../vue-paymentgateway-layer/app/components/Pg/SubscribeButton.vue ../vue-paymentgateway-layer/app/components/Pg/ManageSubscriptionButton.vue ../vue-paymentgateway-layer/app/components/Pg/MySubscription.vue` reports at least 1 per file. + - `grep -c '"already_subscribed": "You already have a subscription. Manage it from your billing account."' ../vue-paymentgateway-layer/i18n/locales/en.json` prints 1 and `grep -c '"already_subscribed": "Masz już subskrypcję.' ../vue-paymentgateway-layer/i18n/locales/pl.json` prints 1. + - `git -C ../vue-paymentgateway-layer diff v0.2.0 --stat -- package.json pnpm-lock.yaml` prints nothing (no dependency or version change in this plan). + + A user who goes to Stripe Checkout or the portal and presses Back finds working Subscribe and Manage buttons, and a user with a past_due subscription is told to manage the subscription they have. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser to customer API | the authenticated user chooses plan and currency; the server decides the price | +| customer API and admin to Stripe | Checkout, customer and plan-sync calls create billable objects | +| admin bulk action to the database | the framework's bulk transaction and its row locks | +| browser history to the layer | back/forward-cache restores JavaScript state | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-18.1-41 | Tampering | `api.Checkout` / `Gateway.CreateCheckoutSession`: a currency without its own price is billed at another currency's price (CR-02) | high | mitigate | `CheckoutPriceID` with no cross-currency fallback in both the API 422 gate and the gateway; the yen refused (Task 1) | +| T-18.1-42 | Tampering | `ensureCustomer` / `SetCustomerID`: concurrent checkouts create two Stripe customers and the last write wins (WR-02) | medium | mitigate | idempotency key per subscriber and parameters; first-write-wins conditional UPDATE with re-read; cancellation-free store (Task 2) | +| T-18.1-43 | Tampering | `api.Checkout`: a user with a past_due, unpaid, incomplete or paused subscription, or two parallel tabs, gets a second billing subscription (WR-03) | high | mitigate | 409 for any open status; `CheckoutGuarded` re-checks under `SubscriberLockKey`; idempotent session create (Task 2) | +| T-18.1-44 | Denial of Service | plan sync holds the bulk transaction and plan row locks across Stripe calls; a rollback loses created ids (WR-01) | medium | mitigate | `Detached` bulk action; per-plan short saves through a cancellation-free context; toolbar sync without a transaction wrapper (Task 3) | +| T-18.1-45 | Elevation of Privilege | a detached bulk action bypasses the list scope, the partial-selection rule or the permissions | medium | mitigate | the detached branch loads through the same `ListExtendQuery` scope with the same empty and 409 rules; permissions are checked before BulkAction as today; the locked path is unchanged (13 TestBulkAction passes) | +| T-18.1-46 | Denial of Service | an idempotent replay returns a stored Stripe failure or rejects changed parameters | low | mitigate | 10-minute window bounds a replayed failure; the parameter hash gives changed parameters a new key; keys are never logged | +| T-18.1-47 | Denial of Service | buttons stay disabled after a back/forward-cache restore (WR-07) | low | mitigate | `watchPageRestore` plus `restore()` in all three components; a throwing navigation resets the state (Task 4) | +| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed: crypto/sha256 and encoding/hex are stdlib, the layer adds no dependency (package.json and pnpm-lock.yaml unchanged) | + + + +- Task 1: vet and -short in both plugin repositories; on Docker the customer API smoke and authz, the service API, the gateway and the host flow pass; pgsub links no stripe-go. +- Task 2: vet and -short in both repositories; the full pgsub Postgres suite, the gateway, host and fresh-state tests pass. +- Task 3: framework vet, Docker-free cabana and pact tests, docs and OpenAPI checks; on Docker all 13 framework bulk-action tests and the pgsub admin, plan-screen, service and core smokes pass. +- Task 4: the layer suite with "# fail 0" and the gate's layers stage. + + + +- CR-02: no checkout is billed at another currency's price; the refusal is 422 at the API and ErrNoPriceForCurrency at the gateway. +- WR-02 and WR-03: one customer per subscriber; no checkout while any subscription is open; parallel requests yield one session per plan and currency. +- WR-01: no Stripe call under a transaction or row lock in either sync action; ids survive a cancelled request. +- WR-07: the buttons recover after Back. +- No public route, event, table or response shape changes; the framework addition is opt-in and documented. + + + +Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-08-SUMMARY.md` when done, with the commits per repository, every adapted existing assertion (file, test, old and new expectation), the smoke results (or NOT RUN with the reason) and a note that the framework needs tag v0.1.27 before the plugins can be built from git without the replace. + diff --git a/.planning/phases/18.1-payment-gateway-subscriptions/18.1-09-PLAN.md b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-09-PLAN.md new file mode 100644 index 0000000..9e7cfdc --- /dev/null +++ b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-09-PLAN.md @@ -0,0 +1,417 @@ +--- +phase: 18.1-payment-gateway-subscriptions +plan: 09 +type: execute +wave: 3 +depends_on: ["18.1-07", "18.1-08"] +files_modified: + - scripts/check-phase18.1.sh + - modules/cabana/bulk_detached_test.go + - modules/cabana/README.md + - ../sm-pgstripe-plugin/subscriptions/ordering_test.go + - ../sm-pgstripe-plugin/subscriptions/gateway_hardening_test.go + - ../sm-pgstripe-plugin/subscriptions/stripefake_test.go + - ../sm-pgstripe-plugin/subscriptions/decode_test.go + - ../sm-pgstripe-plugin/subscriptions/README.md + - ../sm-pgstripe-plugin/go.mod + - ../sm-paymentgatewaysubscriptions-plugin/models/checkout_price_test.go + - ../sm-paymentgatewaysubscriptions-plugin/plan_resolution_test.go + - ../sm-paymentgatewaysubscriptions-plugin/sync_subscription_test.go + - ../sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go + - ../sm-paymentgatewaysubscriptions-plugin/admin_sync_detached_test.go + - ../sm-paymentgatewaysubscriptions-plugin/README.md + - ../sm-paymentgatewaysubscriptions-plugin/go.mod + - ../vue-paymentgateway-layer/tests/subscribe.test.ts + - ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts + - ../vue-paymentgateway-layer/package.json + - ../vue-paymentgateway-layer/README.md + - .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md + - .planning/phases/18.1-payment-gateway-subscriptions/deferred-items.md +autonomous: false +gap_closure: true +requirements: [SC-5, D-22, CR-01, CR-02, WR-01, WR-02, WR-03, WR-04, WR-06, WR-07] +estimate: + tokens: 200000 + raw_tokens: 200000 + tasks: 5 + confidence: low +must_haves: + truths: + - "SC-5 (project rule: unit tests are the last plan): every behaviour plans 07 and 08 changed has a named test in this plan across summercms.go (cabana), sm-paymentgatewaysubscriptions-plugin, sm-pgstripe-plugin and vue-paymentgateway-layer; go vet ./... and go test ./... are green in the three Go repositories and the layer's node --test run reports # fail 0." + - "CR-01 proof (TestEventOrdering, Postgres): each review sequence holds. Checkout race in either order and with two concurrent jobs: the row ends active, activated fires once and no stale snapshot regresses it. Deleted before created: no marker until the row exists, a canceled row, no activated, has_access false. A stale updated after a local cancel fires no reactivated. A failed Stripe retrieval leaves no marker and is retried. A duplicate or another application's event makes no Stripe call." + - "WR-04 and WR-06 proof: TestPlanResolution covers price, stripe_prices, product and plan_code (inactive plan included) and the unresolved error; TestApplyDeletedNotStored covers the sentinel; TestSyncSubscriptionFresh covers the retriever path (plan refreshed from the price), the legacy path (no plan change), gateway-owned columns only (a plan switch, subscriber and customer id written after the caller's load survive), no events, and a sync that waits for a held SubscriptionLockKey." + - "CR-02 proof: TestCheckoutPriceID (Docker-free) covers synced map with and without the currency, unsynced own and other currency, case and an empty map; TestGatewayStrictPrice covers no fallback, the yen refused and no Stripe call on refusal; TestCustomerAPIGuards covers the 422 at the API before any gateway call." + - "WR-02 and WR-03 proof: TestCustomerIDFirstWriteWins covers the first id kept, the stored id adopted, a missing user and that only stripe_customer_id changes. TestCheckoutGuarded covers each of the six open statuses refused without a gateway call, canceled and incomplete_expired allowed, two concurrent guarded checkouts of one subscriber never overlapping at the gateway, two subscribers not blocking each other, and the second request reusing the first's customer. TestGatewayIdempotencyKeys covers the Idempotency-Key header on the customer and session creates: equal within a window for equal parameters, different across windows (Gateway.Now) and for different metadata. TestCustomerAPIGuards covers 409 for past_due, unpaid, incomplete and paused with the existing check order." + - "WR-01 proof: TestDetachedBulkAction (framework, Postgres) shows a detached Run sees no transaction and holds no row lock (another connection's SELECT ... FOR UPDATE NOWAIT succeeds during Run), with the same empty, partial-409 and error rules and earlier writes kept, while a non-detached action is unchanged. TestAdminSyncDetached shows no gateway call of either sync action runs while the database reports the session idle in transaction, a failed save is a failure line, and ids are saved although the request context was cancelled." + - "WR-07 proof: subscribe.test.ts specs named 'subscriptions: ...' cover a persisted pageshow restoring both flows, a non-persisted pageshow changing nothing, watchPageRestore's remover and its server no-op, and a throwing navigation re-enabling the button with the generic error; the components' mount and unmount wiring is asserted, and the 409 copy test pins the new text." + - "Gate: scripts/check-phase18.1.sh requires every new test name in its stage and package (go, plugins, postgres, layers) and raises the layers minimum to the new count of passing 'subscriptions:' tests; --self-test passes; PHASE181_REQUIRE_POSTGRES=1 --all is green on a Docker host; 18.1-VALIDATION.md carries rows for every task of plans 07 to 09 and stays validated and nyquist_compliant." + - "D-22 release: after the gate is green, a final checkpoint lists every unpushed commit of the four repositories and the proposed tags. The framework tag v0.1.27 carries pact.AdminBulkAction.Detached, the layer gets v0.2.1 with package.json 0.2.1, and first plugin tags are an option. Nothing is pushed or tagged before the human's reply. After a framework tag, the plugin go.mod files require it in separate commits before their pushes, in plan 06's order: framework master and tag, require bumps, plugin pushes, layer." + artifacts: + - path: "../sm-pgstripe-plugin/subscriptions/ordering_test.go" + provides: "CR-01 regression suite over the three review sequences" + contains: "TestEventOrdering" + - path: "../sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go" + provides: "WR-02, WR-03 and API guard coverage" + contains: "TestCheckoutGuarded" + - path: "modules/cabana/bulk_detached_test.go" + provides: "framework detached bulk action coverage" + contains: "TestDetachedBulkAction" + - path: "scripts/check-phase18.1.sh" + provides: "fail-closed gate requiring the gap-closure tests" + contains: "TestEventOrdering" + - path: ".planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md" + provides: "validated map including plans 07 to 09" + contains: "18.1-09" + key_links: + - from: "scripts/check-phase18.1.sh" + to: "../sm-pgstripe-plugin/subscriptions/ordering_test.go" + via: "postgres stage required name TestEventOrdering in package ./subscriptions" + pattern: "TestEventOrdering" + - from: "scripts/check-phase18.1.sh" + to: "modules/cabana/bulk_detached_test.go" + via: "postgres stage required name TestDetachedBulkAction in ./modules/cabana" + pattern: "TestDetachedBulkAction" + - from: "../sm-paymentgatewaysubscriptions-plugin/go.mod" + to: "summercms.go tag v0.1.27" + via: "require git.golem15.com/golem15/summercms v0.1.27 after the framework tag (release task)" + pattern: "summercms v0.1.27" + prohibitions: + - statement: "No test calls the network or a real Stripe account; secrets in tests are test-only literals." + status: unresolved + verification: judgment + - statement: "The gate never reports green when a required test skipped, did not run, or Docker was absent without saying NOT RUN." + status: unresolved + verification: judgment + - statement: "Nothing is pushed or tagged without the human's reply at the final checkpoint, and nothing is force-pushed." + status: unresolved + verification: judgment +--- + +## Phase Goal + +ROADMAP Goal (not in user-story format; no story is invented, as in plans 01 to 08): the generic core of `Golem15.PaymentGatewaySubscriptions` and pgstripe's subscription gateway and webhook run on SummerCMS, and a consuming application's subscription flow works as it does on PHP. + +This plan's slice (gap closure 3 of 3, the last wave): SC-5 for the gap closure. It adds unit and Postgres tests for everything plans 07 and 08 changed (CR-01, CR-02, WR-01, WR-02, WR-03, WR-04, WR-06, WR-07), extends the fail-closed `scripts/check-phase18.1.sh`, fills the README Testing sections and the VALIDATION map, and ends with the human's release decision (D-22), mirroring plan 06. + +## Decisions taken in this plan (Claude's discretion) + +- New test files per finding group (ordering, gateway hardening, plan resolution, sync, checkout guard, detached sync, detached bulk action) rather than growing plan 06's files, so the gate names and the review findings map one to one. +- `TestDetachedBulkAction` is named so that it does not match plan 08's `^TestBulkAction` count of 13, and that count stays meaningful. +- The layer's component specs render on the server (no `onMounted`), so the mount and unmount wiring is asserted from the component sources and the behaviour through `watchPageRestore` and the flows. +- Release tags (proposed on the sheet, decided by the human): + - framework `v0.1.27`, needed because pgsub uses `pact.AdminBulkAction.Detached`; + - layer `v0.2.1` with `package.json` 0.2.1; + - first plugin tags `v0.1.0` on pgsub and pgstripe, an option the orchestrator asked for. No plugin tag exists yet. sm-paymentgateway-plugin is untagged too, so a pgsub tag alone still needs consumers' `replace` lines (IN-08, out of scope). + +## Artifacts this phase produces (this plan) + +- `modules/cabana/bulk_detached_test.go`: `TestDetachedBulkAction`. +- `../sm-pgstripe-plugin/subscriptions/ordering_test.go`: `TestEventOrdering` with subtests `checkout_race_updated_first`, `checkout_race_concurrent`, `deleted_before_created`, `stale_cancel_flag_no_reactivated`, `retrieve_error_retries`, `duplicate_makes_no_stripe_call`, `other_app_makes_no_stripe_call`. +- `../sm-pgstripe-plugin/subscriptions/gateway_hardening_test.go`: `TestGatewayStrictPrice`, `TestGatewayIdempotencyKeys`, `TestRetrieveSubscription`. +- `../sm-pgstripe-plugin/subscriptions/stripefake_test.go`: `fakeRequest` keeps the `Idempotency-Key` header; `setFailRetrieve(bool)` makes `GET /v1/subscriptions/{id}` answer 500. +- `../sm-pgstripe-plugin/subscriptions/decode_test.go`: `TestDecodeSubscription` cases for the price's product (string and expanded) and `plan_code`. +- `../sm-paymentgatewaysubscriptions-plugin/models/checkout_price_test.go`: `TestCheckoutPriceID`, `TestTerminalStatuses`. +- `../sm-paymentgatewaysubscriptions-plugin/plan_resolution_test.go`: `TestPlanResolution`, `TestApplyDeletedNotStored`. +- `../sm-paymentgatewaysubscriptions-plugin/sync_subscription_test.go`: `TestSyncSubscriptionFresh`. +- `../sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go`: `TestCheckoutGuarded`, `TestCustomerIDFirstWriteWins`, `TestCustomerAPIGuards`. +- `../sm-paymentgatewaysubscriptions-plugin/admin_sync_detached_test.go`: `TestAdminSyncDetached`. +- Layer specs (names start with "subscriptions:") in `tests/subscribe.test.ts` and `tests/subscriptionComponents.test.ts`; `package.json` version 0.2.1. +- `scripts/check-phase18.1.sh`: the new names in `PG_CABANA_TESTS`, `PGSUB_MODELS_TESTS`, `PG_PGSUB_TESTS`, `PG_PGSTRIPE_SUBS_TESTS`, `LAYER_REQUIRE`, and a raised `LAYER_PREFIX_MIN`. +- README Testing sections (pgsub, pgstripe subscriptions, cabana, layer); `18.1-VALIDATION.md` rows for plans 07 to 09; `deferred-items.md` entries; release commits (go.mod require bumps) and the release record. + + +Prove every gap-closure fix with named tests, require them in the phase gate, record them in the VALIDATION map and the README Testing sections, and publish the result only on the human's reply. + +Purpose: SC-5 and the project rule that unit tests are the last plan; the gate is what /msd-verify-work re-runs for the gap closure. +Output: test commits in four repositories, the extended gate, the updated VALIDATION map, the layer version, and the release record. + +Execution budget: above the single-context smart zone by design, like plan 06. The project rule puts every gap-closure test in this last plan and the user fixed three plans. The mitigation is one task per repository group with a commit per task and repository, SUMMARY notes as you go, and resuming at the first uncommitted task after a reset. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.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-SUMMARY.md +@.planning/phases/18.1-payment-gateway-subscriptions/18.1-08-SUMMARY.md + +Repositories: summercms.go (gate, cabana test, VALIDATION), ../sm-paymentgatewaysubscriptions-plugin, ../sm-pgstripe-plugin, ../vue-paymentgateway-layer. Commit per repository and task; planning docs in their own commit; stage by explicit path; no co-author tags. Docker-backed suites skip under -short; run them on a Docker host (`sudo -u golem` if the socket is denied) and record NOT RUN only when no daemon answers. A test that exposes a production bug gets the fix in the owning repository as its own `fix(18.1-09): ...` commit before the test commit, and the SUMMARY lists it. Several sessions work in summercms.go: never stage or stash another session's files. + + +- Gate: `scripts/check-phase18.1.sh` required lists at :60-101 (`CABANA_SHORT_TESTS`, `PGSUB_MODELS_TESTS`, `PGSUB_CONTRACTS_TESTS`, `PGSUB_ROOT_TESTS`, `PGSTRIPE_ROOT_TESTS`, `PGSTRIPE_SUBS_TESTS`, `PG_CABANA_TESTS`, `PG_PGSUB_TESTS`, `PG_PGSTRIPE_ROOT_TESTS`, `PG_PGSTRIPE_SUBS_TESTS`), layer names `LAYER_REQUIRE` :134-177, `LAYER_PREFIX_MIN=36` :180, detectors and `run_self_test` :195-500. +- pgstripe test harness (package subscriptions_test): `bootSubscriptionsHost`, `subEvent`, `deliver`, `work`, `job`, `activate`, `subscription`, `recorder`, `plan`, `count` (host_test.go); fake `setSubscription`, `calls(method, path)`, `all()`, `reset()`, `record` (stripefake_test.go); `eventBody`, `subscriptionObject`, `invoiceObject` (eventbody_test.go); `TestFreshStateSmoke` from plan 07. +- pgsub test harness (package root): `subEnv` with `plan`, `user`, `tx`, `txErr`, `count`, `capi` (harness_test.go, customer_api_smoke_test.go); the fixture `fakeGateway` (fixture_plugin_test.go); the open-transaction probe of `TestServiceAPI` (service_api_test.go, pg_stat_activity `idle in transaction`); `classes.New(classes.Options{...})` with `contracts.NewGateways()` builds a Service with a test gateway that does or does not implement `contracts.SubscriptionRetriever`. +- Framework bulk test harness: modules/cabana/phase121_bulk_test.go (the 13 `TestBulkAction*` tests and their fixture controller). +- Layer: `createSubscribeFlow`, `createManageFlow`, `watchPageRestore`, `restore()` (app/utils/subscribe.ts after plan 08); test helpers `renderSSR`, `loadComponent`, `stubComponent`, `fakeT` (testing/load.ts); TAP run `pnpm test`. +- Release: plan 06 Task 5 (push sheet, options, order, verify command) and 18.1-06-SUMMARY.md § Task 5 (what ran). + + + + + + + Task 1: Tracer: the CR-01 ordering suite runs end to end through the phase gate (TestEventOrdering required in the postgres stage, fail-closed) + Tests and a test script only. + ../sm-pgstripe-plugin/subscriptions/ordering_test.go, ../sm-pgstripe-plugin/subscriptions/stripefake_test.go, scripts/check-phase18.1.sh + ../sm-pgstripe-plugin/subscriptions/job.go and gateway.go (after plan 07), ../sm-pgstripe-plugin/subscriptions/fresh_state_smoke_test.go, ../sm-pgstripe-plugin/subscriptions/host_test.go (helpers), ../sm-pgstripe-plugin/subscriptions/job_test.go (concurrent subtest pattern), ../sm-pgstripe-plugin/subscriptions/stripefake_test.go, scripts/check-phase18.1.sh (lists :60-101, run_self_test), .planning/phases/18.1-payment-gateway-subscriptions/18.1-REVIEW.md (CR-01 sequences 1 to 3), .planning/phases/18.1-payment-gateway-subscriptions/18.1-07-SUMMARY.md + + - checkout_race_updated_first: the fake holds active; updated (active) is applied, then created (snapshot incomplete); the row is active and activated fired exactly once. + - checkout_race_concurrent: the fake holds active; created (snapshot incomplete) and updated (snapshot active) are worked by two goroutines at once (a retried unique violation is allowed); the row ends active, activated fired once, two markers. + - deleted_before_created: Work of the deletion returns an error wrapping classes.ErrSubscriptionNotStored and leaves no marker; created with the fake canceled stores canceled and fires no activated; the retried deletion succeeds and the row is canceled with has_access false. + - stale_cancel_flag_no_reactivated: after Service.Cancel at period end (the fake now holds cancel_at_period_end true), an older updated snapshot with cancel false fires no reactivated and the flag stays true. + - retrieve_error_retries: with the fake's retrieve failing (500), Work returns an error, no marker, the row is unchanged; after the failure is switched off the same args succeed. + - duplicate_makes_no_stripe_call / other_app_makes_no_stripe_call: no GET /v1/subscriptions call is recorded for a processed event or for an event whose app tag differs from metadata_app; the marker exists in both cases. + + Per SC-5, CR-01 and the D-21 amendment of plan 07. +- stripefake_test.go adds `setFailRetrieve(on bool)`, which makes GET /v1/subscriptions/{id} answer a 500 Stripe error while on; `reset()` clears it. +- Create subscriptions/ordering_test.go (package subscriptions_test) with `TestEventOrdering` and the seven subtests of the behaviour list. Use the host helpers. Build event bodies with `eventBody` and `subscriptionObject` where the snapshot must differ from the fake's state, and call `e.job(t).Work` directly where an error is expected. Each subtest comments the review sequence it pins. +- Gate: add `TestEventOrdering` and plan 07's `TestFreshStateSmoke` to `PG_PGSTRIPE_SUBS_TESTS`. +- Run the self-test and the postgres stage. +- Commit pgstripe (test) and summercms.go (gate) separately. + + bash -n scripts/check-phase18.1.sh && bash scripts/check-phase18.1.sh --self-test && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... + non-zero exit: a syntax error, a detector that accepted a planted failing, skipping, empty or missing output, or vet or a Docker-free pgstripe test fails + docker info >/dev/null 2>&1 && bash scripts/check-phase18.1.sh postgres + non-zero exit: no Docker daemon (record NOT RUN), or a required Postgres test (TestEventOrdering and TestFreshStateSmoke among them) failed, skipped or did not run + + + - `grep -c "t.Run(\"" ../sm-pgstripe-plugin/subscriptions/ordering_test.go` prints at least 7. + - `grep -n "TestEventOrdering\|TestFreshStateSmoke" scripts/check-phase18.1.sh` prints at least two lines. + - `grep -n "func (f \*stripeFake) setFailRetrieve" ../sm-pgstripe-plugin/subscriptions/stripefake_test.go` prints one line. + + The three review sequences of CR-01 are pinned by a Postgres suite that the gate requires by name, so a regression to snapshot application turns the gate red. + + + + Task 2: pgsub coverage: checkout price, terminal statuses, plan resolution, deletion sentinel, fresh sync, guarded checkout, first-write-wins customer id, API guards and the detached admin sync + Tests only (plus any bug fix they expose, committed separately). + ../sm-paymentgatewaysubscriptions-plugin/models/checkout_price_test.go, ../sm-paymentgatewaysubscriptions-plugin/plan_resolution_test.go, ../sm-paymentgatewaysubscriptions-plugin/sync_subscription_test.go, ../sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go, ../sm-paymentgatewaysubscriptions-plugin/admin_sync_detached_test.go, ../sm-paymentgatewaysubscriptions-plugin/README.md, scripts/check-phase18.1.sh + ../sm-paymentgatewaysubscriptions-plugin/classes (apply.go, service.go, lock.go, user_subscribable.go after plans 07 and 08), ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go and subscription.go, ../sm-paymentgatewaysubscriptions-plugin/api/subscription.go, ../sm-paymentgatewaysubscriptions-plugin/controllers/subscriptionplans.go, ../sm-paymentgatewaysubscriptions-plugin/harness_test.go, fixture_plugin_test.go, service_api_test.go (the open-transaction probe), admin_actions_test.go and admin_harness_test.go (how the admin actions are posted), customer_api_smoke_test.go, ../sm-paymentgatewaysubscriptions-plugin/README.md (Testing), .planning/phases/18.1-payment-gateway-subscriptions/18.1-08-SUMMARY.md + + - CheckoutPriceID: map {pln, usd} answers pln and usd ids and "" for eur even with stripe_price_id set; no map and plan currency USD answers stripe_price_id for usd and USD and "" for pln; an empty map behaves as no map; currency case and spaces are ignored. + - TerminalStatuses is exactly canceled and incomplete_expired; the other six statuses are open. + - Plan resolution: an unknown price with a matching stripe_product_id stores that plan; with neither but a plan_code of an inactive plan stores that plan; nothing matching on a new row is an error naming the price, product and code; an existing row keeps its plan. + - ApplyDeleted of an unknown id: errors.Is ErrSubscriptionNotStored, nil row, no event. + - SyncSubscription: a retriever gateway on another plan's price moves the row to that plan; a plan switch, customer id and subscriber written after the caller's load survive; only gateway-owned columns change; no event; the legacy gateway path changes status but not the plan; a held SubscriptionLockKey on another connection delays the sync until released. + - CheckoutGuarded: each open status is ErrAlreadySubscribed with zero gateway calls; canceled and incomplete_expired proceed; two concurrent guarded checkouts of one subscriber never have two gateway calls in flight (an atomic in-flight counter stays at most 1) and create the customer once; two subscribers proceed in parallel. + - SetCustomerID: a second write keeps the first id and the subscriber answers it; a missing user is ErrSubscriberNotFound; no other users column or updated_at changes. + - API guards: past_due, unpaid, incomplete and paused answer 409 Already subscribed; an open subscription plus an unknown plan still answers 409 (check order unchanged); a currency without a gateway price answers 422; no gateway call in any refusal. + - Admin sync: neither action makes a gateway call while pg_stat_activity shows the session idle in transaction; a save that fails (a test trigger raising on UPDATE of one plan) is a failure line and not synced; ids are saved when the request context is cancelled during the gateway call. + + Per SC-5, CR-01, CR-02, WR-01, WR-02, WR-03, WR-04 and WR-06 (D-08, D-09, D-13 unchanged): write the tests against the behaviour of plans 07 and 08, fix any production bug they expose in its own commit, then extend the gate. +- Docker-free: models/checkout_price_test.go holds `TestCheckoutPriceID` and `TestTerminalStatuses`. +- Postgres, root package, existing harness: + - plan_resolution_test.go: `TestPlanResolution` and `TestApplyDeletedNotStored`; + - sync_subscription_test.go: `TestSyncSubscriptionFresh`. Build Services with `classes.New` and a `contracts.NewGateways()` registry holding a test gateway with, and one without, `RetrieveSubscription`. Hold the lock with `classes.WithSessionLock` in a goroutine; + - checkout_guard_test.go: `TestCheckoutGuarded`, `TestCustomerIDFirstWriteWins` and `TestCustomerAPIGuards`, through the customer API helper; + - admin_sync_detached_test.go: `TestAdminSyncDetached`. Reuse the open-transaction probe pattern of TestServiceAPI in a probe gateway. The save failure comes from a test-created trigger dropped in cleanup. +- Each test comments the review finding it pins. +- Gate: `PGSUB_MODELS_TESTS` gains TestCheckoutPriceID and TestTerminalStatuses; `PG_PGSUB_TESTS` gains the seven Postgres names. +- README (pgsub) Testing lists every new name. +- Commit pgsub (tests and README) and summercms.go (gate) separately. + + go -C ../sm-paymentgatewaysubscriptions-plugin vet ./... && go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -short ./... && bash scripts/check-phase18.1.sh plugins && bash scripts/check-phase18.1.sh --self-test + non-zero exit: vet or a Docker-free pgsub test fails, or the plugins stage reports a required name failed, skipped or missing + docker info >/dev/null 2>&1 && OUT=$(go -C ../sm-paymentgatewaysubscriptions-plugin test -count=1 -run '^(TestPlanResolution|TestApplyDeletedNotStored|TestSyncSubscriptionFresh|TestCheckoutGuarded|TestCustomerIDFirstWriteWins|TestCustomerAPIGuards|TestAdminSyncDetached)$' -v . 2>&1) && printf '%s\n' "$OUT" | grep -c '^--- PASS: Test' | grep -qx 7 + non-zero exit: no Docker daemon (record NOT RUN), or fewer than seven pgsub top-level PASS lines (a resolution step, the sentinel, a sync column, a guard, the first write, an API refusal or a detached sync property failed) + + + - `grep -c "func Test" ../sm-paymentgatewaysubscriptions-plugin/plan_resolution_test.go ../sm-paymentgatewaysubscriptions-plugin/sync_subscription_test.go ../sm-paymentgatewaysubscriptions-plugin/checkout_guard_test.go ../sm-paymentgatewaysubscriptions-plugin/admin_sync_detached_test.go ../sm-paymentgatewaysubscriptions-plugin/models/checkout_price_test.go` reports at least 1 per file. + - `grep -n "TestCheckoutGuarded\|TestSyncSubscriptionFresh\|TestCheckoutPriceID" scripts/check-phase18.1.sh` prints at least three lines. + - `grep -n "TestAdminSyncDetached" ../sm-paymentgatewaysubscriptions-plugin/README.md` prints at least one line. + - `grep -n "idle in transaction" ../sm-paymentgatewaysubscriptions-plugin/admin_sync_detached_test.go` prints at least one line. + + Every pgsub behaviour plans 07 and 08 changed has a test that fails when it regresses, and the gate requires them by name. + + + + Task 3: pgstripe and framework coverage: strict gateway price, idempotency keys, subscription retrieval and decoding, and the framework detached bulk action + Tests and a fake only (plus any bug fix they expose, committed separately). + ../sm-pgstripe-plugin/subscriptions/gateway_hardening_test.go, ../sm-pgstripe-plugin/subscriptions/stripefake_test.go, ../sm-pgstripe-plugin/subscriptions/decode_test.go, ../sm-pgstripe-plugin/subscriptions/README.md, modules/cabana/bulk_detached_test.go, modules/cabana/README.md, scripts/check-phase18.1.sh + ../sm-pgstripe-plugin/subscriptions/gateway.go (after plans 07 and 08), ../sm-pgstripe-plugin/subscriptions/gateway_test.go (harness and settings setup), ../sm-pgstripe-plugin/subscriptions/stripefake_test.go (record, fakeRequest), ../sm-pgstripe-plugin/subscriptions/decode_test.go, ../sm-pgstripe-plugin/subscriptions/README.md (Testing), modules/cabana/crud.go (BulkAction after plan 08), modules/cabana/phase121_bulk_test.go (fixture controller and helpers), modules/cabana/README.md (Testing), .planning/phases/18.1-payment-gateway-subscriptions/18.1-08-SUMMARY.md + + - Strict price: a plan whose map lacks the currency gets ErrNoPriceForCurrency and the fake records no call although stripe_price_id is set; an unsynced plan checks out in its own currency on stripe_price_id and is refused in another; SupportsCurrency refuses the yen code and accepts usd, eur, pln, gbp, cad, aud and chf. + - Idempotency keys: POST /v1/customers and POST /v1/checkout/sessions carry an Idempotency-Key header starting "pgsub-customer-" and "pgsub-checkout-"; two calls with equal parameters and Gateway.Now in one window send the same key; Now plus 10 minutes sends another; different checkout metadata sends another. + - Retrieval: RetrieveSubscription maps status, both period shapes, price, product (string or expanded), plan_code and the subscriber metadata; a 404 wraps ErrSubscriptionMissing; a 500 is another error. + - Decode: TestDecodeSubscription adds the product id and plan_code mappings; job arguments still hold no e-mail, name or address. + - Framework: a Detached action's Run sees cabana.TxFromContext report false; during Run another connection's SELECT ... FOR UPDATE NOWAIT on a selected row succeeds; empty selection gives affected 0 without Run; a partial selection is 409; a cabana.ValidationError from Run is 422; a row Run wrote before returning that error stays written; a non-detached action of the same fixture still sees a transaction and holds the lock. + + Per SC-5, CR-01, CR-02, WR-01, WR-02, WR-03 and WR-04: tests first, bug fixes in their own commits, then the gate. +- stripefake_test.go keeps the request's Idempotency-Key header in `fakeRequest`. +- Create subscriptions/gateway_hardening_test.go with `TestGatewayStrictPrice`, `TestGatewayIdempotencyKeys` (set `Gateway.Now` on a gateway built with `subscriptions.NewGateway`) and `TestRetrieveSubscription`, all Postgres for the settings. Extend decode_test.go `TestDecodeSubscription`. +- Framework: create modules/cabana/bulk_detached_test.go with `TestDetachedBulkAction` (Postgres) on the phase121 fixture pattern. Add a detached action to a test-only controller, so no production fixture changes. The test names no application or provider. +- READMEs Testing: + - pgstripe subscriptions: the three new names and TestEventOrdering; + - cabana: TestDetachedBulkAction. +- Gate: + - `PG_PGSTRIPE_SUBS_TESTS` gains TestGatewayStrictPrice, TestGatewayIdempotencyKeys and TestRetrieveSubscription; + - `PG_CABANA_TESTS` gains TestDetachedBulkAction. +- Commit framework test and README, pgstripe tests and README, and the gate separately. + + go vet ./... && go test -count=1 -short ./modules/cabana && go -C ../sm-pgstripe-plugin vet ./... && go -C ../sm-pgstripe-plugin test -count=1 -short ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check && bash scripts/check-phase18.1.sh go && bash scripts/check-phase18.1.sh plugins + non-zero exit: vet, a Docker-free cabana or pgstripe test (TestDecodeSubscription among them), the docs checkers on the README changes, or the go or plugins stage of the gate + docker info >/dev/null 2>&1 && OUT=$(go test -count=1 -run '^TestDetachedBulkAction$' -v ./modules/cabana 2>&1) && printf '%s\n' "$OUT" | grep -q '^--- PASS: TestDetachedBulkAction' && OUT2=$(go -C ../sm-pgstripe-plugin test -count=1 -run '^(TestGatewayStrictPrice|TestGatewayIdempotencyKeys|TestRetrieveSubscription)$' -v ./subscriptions 2>&1) && printf '%s\n' "$OUT2" | grep -c '^--- PASS: Test' | grep -qx 3 + non-zero exit: no Docker daemon (record NOT RUN), the framework detached test did not pass, or fewer than three pgstripe top-level PASS lines + + + - `grep -n "Idempotency-Key" ../sm-pgstripe-plugin/subscriptions/stripefake_test.go ../sm-pgstripe-plugin/subscriptions/gateway_hardening_test.go` prints at least two lines. + - `grep -n "FOR UPDATE NOWAIT" modules/cabana/bulk_detached_test.go` prints at least one line. + - `grep -rniE "stripe|subscription|horoskopia|queststream|quotify" modules/cabana/bulk_detached_test.go` prints nothing. + - `grep -n "TestDetachedBulkAction\|TestGatewayStrictPrice\|TestRetrieveSubscription" scripts/check-phase18.1.sh` prints at least three lines. + + The Stripe half of the gap closure and the framework addition are covered by named tests the gate requires. + + + + Task 4: Layer coverage and version 0.2.1, the complete gate run with Postgres required, the VALIDATION map and the deferred items + Tests, a version string and planning documents (plus any bug fix they expose, committed separately). + ../vue-paymentgateway-layer/tests/subscribe.test.ts, ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts, ../vue-paymentgateway-layer/package.json, ../vue-paymentgateway-layer/README.md, scripts/check-phase18.1.sh, .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md, .planning/phases/18.1-payment-gateway-subscriptions/deferred-items.md + ../vue-paymentgateway-layer/app/utils/subscribe.ts and the three Pg subscription components (after plan 08), ../vue-paymentgateway-layer/tests/subscribe.test.ts and tests/subscriptionComponents.test.ts (style, names), ../vue-paymentgateway-layer/testing/load.ts, ../vue-paymentgateway-layer/package.json, ../vue-paymentgateway-layer/README.md (Testing), scripts/check-phase18.1.sh (LAYER_REQUIRE, LAYER_PREFIX_MIN), .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md, .planning/phases/18.1-payment-gateway-subscriptions/deferred-items.md, the SUMMARYs of plans 07 and 08 + + - "subscriptions: a persisted pageshow restores the subscribe flow after a redirect": after subscribe assigned the URL (redirecting true), a stub target dispatching pageshow with persisted true leaves busy and redirecting false, and a second subscribe sends a request. + - "subscriptions: a non-persisted pageshow changes nothing": redirecting stays true. + - "subscriptions: watchPageRestore removes its listener and is a no-op without a target". + - "subscriptions: a throwing navigation re-enables the button with the generic error". + - "subscriptions: the manage flow restores after a persisted pageshow and cancel works again". + - "subscriptions: the three components wire watchPageRestore on mount and remove it on unmount" (from the component sources, since specs render on the server). + - "subscriptions: the already-subscribed copy covers any open subscription in en and pl". + + Per SC-5, WR-07 and the WR-03 copy (D-14): +- Write the seven specs of the behaviour list. Use a stub `PageRestoreTarget` whose dispatch calls the registered listener with an event object carrying `persisted`. +- Set the layer `package.json` version to 0.2.1 in its own commit (no dependency change; pnpm-lock.yaml unchanged). +- The layer README Testing section lists the new specs. A short "Changes in 0.2.1" note describes the back/forward-cache restore and the copy. +- Gate: add the seven names to `LAYER_REQUIRE` and raise `LAYER_PREFIX_MIN` to the number of passing "subscriptions:" tests the run reports. +- Run `PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all` on a Docker host. +- VALIDATION: add one row per task of plans 07, 08 and 09 (requirement or finding ID, behaviour, type, automated command, file exists, status green or NOT RUN with the reason) and keep `status: validated`, `nyquist_compliant: true` and `wave_0_complete: true`. The manual-only rows stay: live test-mode checkout (now also watching that created and updated arrive in either order without a regression), the portal cancel_at check, and the browser pass (now including Back from Checkout). +- deferred-items.md gains one entry "Review findings outside the gap closure": WR-05 (PHP-identical grace, kept per the user's scope) and IN-01 to IN-08, status open, with the review as the source. The check-git-install.sh item stays open. +- Commit the layer, then the gate, then the planning docs. + + OUT=$(pnpm --dir ../vue-paymentgateway-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && bash scripts/check-phase18.1.sh layers && bash scripts/check-phase18.1.sh --all && grep -q "nyquist_compliant: true" .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md && grep -q "18.1-09 T5" .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md + non-zero exit: a layer spec failed ("# fail 0" absent), the layers stage counted fewer passing "subscriptions:" tests than the raised minimum or missed a required name, any gate stage is red, or the VALIDATION map lacks the plan 09 rows or the Nyquist flag + docker info >/dev/null 2>&1 && PHASE181_REQUIRE_POSTGRES=1 bash scripts/check-phase18.1.sh --all + non-zero exit: no Docker daemon (record NOT RUN and leave the affected VALIDATION rows NOT RUN), or a required Postgres test in any repository failed, skipped or did not run + + + - `cat ../vue-paymentgateway-layer/tests/subscribe.test.ts ../vue-paymentgateway-layer/tests/subscriptionComponents.test.ts | grep -c "test('subscriptions: "` is at least 7 higher than before this task (the SUMMARY records both counts). + - `grep -n "a persisted pageshow restores the subscribe flow after a redirect" ../vue-paymentgateway-layer/tests/subscribe.test.ts` prints one line. + - `grep -n '"version": "0.2.1"' ../vue-paymentgateway-layer/package.json` prints one line and `git -C ../vue-paymentgateway-layer diff v0.2.0 --stat -- pnpm-lock.yaml` prints nothing. + - `grep -n "LAYER_PREFIX_MIN=" scripts/check-phase18.1.sh` prints a value of at least 43. + - `grep -n "18.1-07 T1\|18.1-08 T1\|18.1-09 T1" .planning/phases/18.1-payment-gateway-subscriptions/18.1-VALIDATION.md` prints three lines. + - `grep -n "WR-05" .planning/phases/18.1-payment-gateway-subscriptions/deferred-items.md` prints at least one line. + + The layer fix is pinned by specs, the whole gate is green across four repositories with Postgres required, and the VALIDATION map covers the gap closure. + + + + Task 5: Final push approval: unpushed commits of the four repositories, framework tag v0.1.27 and the plugin require bumps, the layer tag v0.2.1, optional first plugin tags + .planning/phases/18.1-payment-gateway-subscriptions/18.1-06-PLAN.md (Task 5) and 18.1-06-SUMMARY.md (§ Task 5: order, commands, checks), the SUMMARYs of plans 07 and 08 and this plan's Task 4 notes (gate result, NOT RUN suites), deferred-items.md (check-git-install.sh) + Mirror plan 06 Task 5 (D-22): do everything automatable, print the push sheet, then stop. + +Per repository (summercms.go, ../sm-paymentgatewaysubscriptions-plugin, ../sm-pgstripe-plugin, ../vue-paymentgateway-layer): +- the tree is clean for this phase's files (never stage another session's files); +- run `git fetch origin --tags`; +- origin has no commits the checkout lacks (otherwise stop and report); +- list the commits that would be pushed. For summercms.go this includes any other session's commits on master, named as such. + +The proposed tags: +- framework `v0.1.27` on the commit after this plan's last docs commit; it contains `pact.AdminBulkAction.Detached`; +- layer `v0.2.1` on the commit with package.json 0.2.1; +- optional first plugin tags `v0.1.0` on pgsub and pgstripe. No earlier tag exists; sm-paymentgateway-plugin stays untagged, so consumers still need its replace. + +The order (plan 06's), which keeps consumers resolvable: +1. framework master and the v0.1.27 tag; +2. in pgsub and in pgstripe, a separate commit raising `require git.golem15.com/golem15/summercms` to v0.1.27 (replace lines unchanged; `go mod tidy`, vet and -short green); +3. with plugin tags: pgsub main and its tag first, then a separate pgstripe commit raising its pgsub require to v0.1.0, then pgstripe main and its tag; without them, pgsub main then pgstripe main; +4. the layer main and v0.2.1. + +Record the gate result, the NOT RUN suites, and the open check-git-install.sh item: an install check of v0.2.1 needs that script fixed first. + +No push, tag or require bump before the reply. After the reply: +- "push-all": the order without plugin tags; +- "push-all-with-plugin-tags": the order with plugin tags; +- "push-no-tags": push branches only (no tags, no require bumps); +- "hold": nothing. + +Never force-push. Record the reply verbatim, every pushed hash and every tag. + Publish the gap closure now? + Tags are read by the Go module proxy and by applications' pinned layer dependency and cannot be moved. The plugins use pact.AdminBulkAction.Detached, so a consumer building pgsub from git without the replace needs framework v0.1.27 (D-22: no push, tag or deploy without the go-ahead). + + + + + + + Reply "push-all", "push-all-with-plugin-tags", "push-no-tags" or "hold". + + for r in . ../sm-paymentgatewaysubscriptions-plugin ../sm-pgstripe-plugin ../vue-paymentgateway-layer; do ST=$(git -C "$r" status --porcelain -- . ':!.planning' ':!.msd') || exit 1; test -z "$ST" || exit 1; done; bash scripts/check-phase18.1.sh --all && echo final-push-sheet-ok + non-zero exit or "final-push-sheet-ok" absent: a repository has uncommitted non-planning changes, or the gate is red. Changes of this phase are committed and a red gate is fixed before the sheet is presented; another session's uncommitted files stop the checkpoint with a report (never staged or stashed). + + + - The SUMMARY records the sheet, the reply verbatim and, after a push reply, for every repository `git rev-parse main` (master for summercms.go) equal to the origin branch. + - After "push-all" or "push-all-with-plugin-tags": `git ls-remote --tags origin v0.1.27` in summercms.go and `git -C ../vue-paymentgateway-layer ls-remote --tags origin v0.2.1` each print one line, and `grep -n "summercms v0.1.27" ../sm-paymentgatewaysubscriptions-plugin/go.mod ../sm-pgstripe-plugin/go.mod` prints two lines. + - After "push-all-with-plugin-tags": `git -C ../sm-paymentgatewaysubscriptions-plugin ls-remote --tags origin v0.1.0` and the same for pgstripe each print one line, and `grep -n "sm-paymentgatewaysubscriptions-plugin v0.1.0" ../sm-pgstripe-plugin/go.mod` prints one line. + - After "hold": no `git ls-remote` shows a new tag and the SUMMARY lists every unpushed commit. + + The human decided how the gap closure is published, and the record says exactly what was pushed and tagged. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| gate script to the developer | the gate's verdict decides whether re-verification starts | +| tests to external services | tests run against fakes and testcontainers only | +| local checkouts to remotes | the final push publishes code and immovable tags | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-18.1-48 | Repudiation | check-phase18.1.sh false green on the gap-closure tests | high | mitigate | every new test is required by name per stage and package; the detectors fail on skip, zero tests, missing names and too few "subscriptions:" passes; --self-test proves them; NOT RUN postgres is fatal with PHASE181_REQUIRE_POSTGRES=1 | +| T-18.1-49 | Information Disclosure | tests calling Stripe or leaking secrets | medium | mitigate | every Stripe call goes to the package fake through BackendOverride; idempotency keys and secrets in tests are test-only values and never printed | +| T-18.1-50 | Tampering | publishing red or unreviewed code; tags that cannot be moved; a plugin require naming a tag that does not exist | medium | mitigate | the final checkpoint runs the full gate first, pushes only after the human's reply, in plan 06's order (framework tag before the require bumps), and never force-pushes | +| T-18.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package is installed; the layer version bump changes no dependency and pnpm-lock.yaml stays unchanged | + + + +- Task 1: self-test, Docker-free pgstripe suite; the postgres stage on a Docker host with TestEventOrdering and TestFreshStateSmoke required. +- Task 2: pgsub Docker-free suite, the plugins stage and the self-test; the seven pgsub Postgres names on a Docker host. +- Task 3: framework and pgstripe Docker-free suites, docs checks, the go and plugins stages; TestDetachedBulkAction and the three pgstripe names on a Docker host. +- Task 4: the layer suite, the layers stage, `--all`, the VALIDATION frontmatter; `PHASE181_REQUIRE_POSTGRES=1 --all` on a Docker host. +- Task 5: clean trees and a green gate before the human decides the publication. + + + +- SC-5 for the gap closure: every fix of plans 07 and 08 has a named test in this last plan; `scripts/check-phase18.1.sh --all` is green with Postgres required (or recorded NOT RUN). +- 18.1-VALIDATION.md covers plans 07 to 09 and stays validated and Nyquist-compliant. +- The human decided the release (D-22) and the record lists every push and tag. + + + +Create `.planning/phases/18.1-payment-gateway-subscriptions/18.1-09-SUMMARY.md` when done, with the commits per repository, every test added, any bug fixed, the gate output summary, the VALIDATION status, the push reply and every pushed hash and tag. +