From 577f712b0a53892675919ae7225b5059d37d0d0e Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 14 Oct 2026 17:27:46 +0200 Subject: [PATCH] docs(18.1-02): complete pgsub core plan --- .../18.1-02-SUMMARY.md | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 .planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md diff --git a/.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md new file mode 100644 index 0000000..3ac5918 --- /dev/null +++ b/.planning/phases/18.1-payment-gateway-subscriptions/18.1-02-SUMMARY.md @@ -0,0 +1,282 @@ +--- +phase: 18.1-payment-gateway-subscriptions +plan: 02 +subsystem: payments +tags: [subscriptions, pgsub, postgres, jsonb, gorm, festival, registry, testcontainers] + +# Dependency graph +requires: + - phase: 18-payment-gateway + provides: the operator registry pattern (bootOperators over party.Activated), typed events with Tx, the plugin repo layout and harness, sm-paymentgateway-plugin (Requires) + - phase: 18.1-01 + provides: nothing used directly (framework compiled through replace ../summercms.go; tag v0.1.26 held) +provides: + - "new repository sm-paymentgatewaysubscriptions-plugin (module git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin, package pgsub, plugin golem15.paymentgatewaysubscriptions), pushed to git.golem15.com at aad4647" + - "idempotent v2.4.0 baseline 202610150001_create_golem15_pgsub_baseline with JSONB JSON columns, and 202610150002_add_stripe_customer_id_to_users" + - "contracts.Gateway (incl. SyncPlanToGateway), Subscriber/SubscriberResolver, build-time Subscribers and Gateways registries with HasSubscribables/HasSubscriptionGateways" + - "classes.Service: Apply with the PHP event diff, ApplyDeleted, ApplyInvoice, ClaimEvent, EventProcessed and the D-13 service API; classes.UserSubscribable" + - "six typed events with the PHP golem15.pgsub.* names" +affects: [18.1-03, 18.1-04, 18.1-06, sm-pgstripe-plugin, vue-paymentgateway-layer] + +# Actuals (#2632) - chars/4 over the realized diff (whole new repo, go.sum included) +actuals: + tokens: 47958 + tasks: 5 + commits: 3 +plan_head_before: none (new repository, root commit 11a1730) +plan_head_after: aad4647dcdf4c792f9fef9d50f34db76e4c18272 +commits: 3 + +tech-stack: + added: [] + patterns: + - "Gateway-neutral core: gateways map their payload to contracts.Update and call Service.Apply; the gateway never writes the database" + - "Build-time morph registry collected in Boot from party.Activated (HasSubscribables, HasSubscriptionGateways), duplicates fail boot naming both plugins" + - "Claim-first processed-event marker (INSERT ... ON CONFLICT (event_id) DO NOTHING) inside the applying transaction" + - "lagoon.Jsonable over JSONB columns (no adapter needed)" + +key-files: + created: + - ../sm-paymentgatewaysubscriptions-plugin/plugin.go + - ../sm-paymentgatewaysubscriptions-plugin/boot.go + - ../sm-paymentgatewaysubscriptions-plugin/contracts/gateway.go + - ../sm-paymentgatewaysubscriptions-plugin/contracts/subscriber.go + - ../sm-paymentgatewaysubscriptions-plugin/contracts/registry.go + - ../sm-paymentgatewaysubscriptions-plugin/models/subscription.go + - ../sm-paymentgatewaysubscriptions-plugin/models/subscription_plan.go + - ../sm-paymentgatewaysubscriptions-plugin/models/processed_webhook_event.go + - ../sm-paymentgatewaysubscriptions-plugin/models/feature_list.go + - ../sm-paymentgatewaysubscriptions-plugin/models/registry.go + - ../sm-paymentgatewaysubscriptions-plugin/events/events.go + - ../sm-paymentgatewaysubscriptions-plugin/classes/service.go + - ../sm-paymentgatewaysubscriptions-plugin/classes/apply.go + - ../sm-paymentgatewaysubscriptions-plugin/classes/user_subscribable.go + - ../sm-paymentgatewaysubscriptions-plugin/updates/registry.go + - ../sm-paymentgatewaysubscriptions-plugin/updates/202610150001_create_golem15_pgsub_baseline.go + - ../sm-paymentgatewaysubscriptions-plugin/updates/202610150002_add_stripe_customer_id_to_users.go + - ../sm-paymentgatewaysubscriptions-plugin/config/config.yaml + - ../sm-paymentgatewaysubscriptions-plugin/lang/en/lang.yaml + - ../sm-paymentgatewaysubscriptions-plugin/lang/pl/lang.yaml + - ../sm-paymentgatewaysubscriptions-plugin/README.md + - ../sm-paymentgatewaysubscriptions-plugin/testmain_test.go + - ../sm-paymentgatewaysubscriptions-plugin/harness_test.go + - ../sm-paymentgatewaysubscriptions-plugin/fixture_plugin_test.go + - ../sm-paymentgatewaysubscriptions-plugin/tracer_test.go + - ../sm-paymentgatewaysubscriptions-plugin/core_smoke_test.go + modified: [] + +key-decisions: + - "Baseline JSON columns (stripe_prices, currency_prices, features) are JSONB (human reply baseline-jsonb); lagoon.Jsonable scans and writes JSONB unchanged" + - "Gateways never write the database; the service stores the customer id in its own short transaction and saves gateway-mutated rows" + - "The service writes cancel_at_period_end locally after a successful cancel at period end, without events" + - "Plan lookup by price id is exact (stripe_price_id, then stripe_prices values), never LIKE" + - "Plan rules use the lagoon model rule set (size:3 as between:3,3, code max:50); no BeforeValidate fallback (cabana CRUD maps a BeforeValidate error to a capability error, not a 422)" + - "Repository created with tea and main pushed at aad4647 (human reply create-and-push)" + +patterns-established: + - "pgsub fixture host: golem15.user + golem15.paymentgateway + pgsub + acme.members (user subscriber, recording fake stripe gateway, event recorder published on the app)" + +requirements-completed: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-05, D-06, D-08, D-09, D-13, D-16, D-22] + +coverage: + - id: D1 + description: "Idempotent v2.4.0 baseline with JSONB columns, the stripe_status CHECK, the RESTRICT FK, no dormant tables; users.stripe_customer_id only when missing" + requirement: SC-1 + verification: + - kind: integration + ref: "core_smoke_test.go#TestSubscriptionsCoreSmoke/baseline_idempotent, users_column_preexisting; tracer_test.go#TestPlanJSONBColumns" + status: pass + human_judgment: false + - id: D2 + description: "Build-time subscriber and gateway registries; duplicate alias fails boot; unknown alias fails loudly; built-in user subscriber checkout stores the customer id" + requirement: SC-1 + verification: + - kind: integration + ref: "tracer_test.go#TestSubscriptionsCheckout; core_smoke_test.go#TestSubscriptionsCoreSmoke/duplicate_alias, unknown_alias" + status: pass + human_judgment: false + - id: D3 + description: "Gateway-neutral Apply with the PHP event diff, ApplyDeleted, ApplyInvoice and the processed-event claim" + requirement: SC-2 + verification: + - kind: integration + ref: "tracer_test.go#TestSubscriptionsTracer; core_smoke_test.go#TestSubscriptionsCoreSmoke/event_diff, invoices, claim" + status: pass + human_judgment: false + - id: D4 + description: "Service API: plans, latest/active, access and grace, features, checkout, portal, cancel, sync subscription, sync plan; grace_period_days config" + requirement: SC-3 + verification: + - kind: integration + ref: "core_smoke_test.go#TestSubscriptionsCoreSmoke/grace, sync_plan_free, sync_plan_paid, cancel_at_period_end, service_api, plan_rules" + status: pass + human_judgment: false + +duration: 45min +completed: 2026-10-14 +status: complete +--- + +# Phase 18.1 Plan 02: pgsub Core Summary + +**New repository `sm-paymentgatewaysubscriptions-plugin` with the gateway-neutral subscriptions core. It holds an idempotent JSONB v2.4.0 baseline, build-time subscriber and gateway registries, the built-in `user` subscriber and `classes.Service`. The service covers Apply with the PHP event diff, invoices, the claim-first event marker and the D-13 service API. Postgres tests prove all of it, and main is pushed to git.golem15.com at `aad4647`.** + +## Performance + +- **Duration:** about 45 min, including the wait for the Task 5 reply +- **Started:** 2026-10-14T14:41:36Z +- **Completed:** 2026-10-14 +- **Tasks:** 5 (2 checkpoints, 3 implementation) +- **Files:** 30 in the new repository + +## Repository + +- Path: `../sm-paymentgatewaysubscriptions-plugin`, module `git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin`, branch `main`. +- go.mod requires `summercms v0.1.25` with `replace ../summercms.go` (v0.1.26 is held) and `sm-paymentgateway-plugin v0.0.0` with `replace ../sm-paymentgateway-plugin`. go.work also replaces `sm-user-plugin` with `../sm-summercmsio-app/plugins/golem15/user`. No new third-party module was added. +- Remote: `git@git.golem15.com:golem15/sm-paymentgatewaysubscriptions-plugin.git`, created with `tea repos create --owner golem15 --name sm-paymentgatewaysubscriptions-plugin`. `main` was pushed with `-u`. +- **Pushed hash:** `aad4647dcdf4c792f9fef9d50f34db76e4c18272`; `rev-parse main` equals `rev-parse origin/main`. No tags were pushed, and the framework repo was not pushed. + +## Task Commits + +| Task | Name | Commit | Repo | +|------|------|--------|------| +| 1 | Baseline confirmation (checkpoint:decision) | none (no files) | n/a | +| 2 | Tracer: plugin, baseline, Apply, activated event, access | `11a1730` | sm-paymentgatewaysubscriptions-plugin | +| 3 | Registries, Gateway contract, built-in user subscriber, checkout | `5388ffb` | sm-paymentgatewaysubscriptions-plugin | +| 4 | Complete core: checks, event diff, invoices, claim, service API, config, copy, smoke | `aad4647` | sm-paymentgatewaysubscriptions-plugin | +| 5 | Create the remote and push (checkpoint:decision) | pushed `aad4647` | remote | + +## Task 1: baseline sheet and reply + +**Reply (verbatim):** "baseline-jsonb" + +**Migration `202610150001_create_golem15_pgsub_baseline`.** Every statement uses `IF NOT EXISTS`. The rollback drops the three tables IF EXISTS in dependency order. + +- **`golem15_pgsub_subscription_plans`:** + - Columns: id SERIAL PK, code VARCHAR(50) NOT NULL, name VARCHAR(255) NOT NULL, description TEXT NULL, stripe_price_id VARCHAR(255) NULL, stripe_product_id VARCHAR(255) NULL, **stripe_prices JSONB NULL**, price_cents INTEGER NOT NULL DEFAULT 0, **currency_prices JSONB NULL**, currency VARCHAR(3) NOT NULL DEFAULT 'EUR', billing_interval VARCHAR(20) NOT NULL DEFAULT 'month', **features JSONB NULL**, max_quotes_per_month INTEGER NULL, lead_fee_required BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT TRUE, sort_order INTEGER NOT NULL DEFAULT 0, created_at and updated_at TIMESTAMPTZ NULL. + - Indexes: `golem15_pgsub_subscription_plans_code_unique`, `_code_index`, `_is_active_index`, `_sort_order_index`. +- **`golem15_pgsub_subscriptions`:** + - Columns: id SERIAL PK, subscriber_type VARCHAR(255) NULL, subscriber_id INTEGER NULL, gateway VARCHAR(20) NOT NULL DEFAULT 'stripe', subscription_plan_id INTEGER NOT NULL, stripe_subscription_id VARCHAR(255) NULL, stripe_customer_id VARCHAR(255) NULL, stripe_status VARCHAR(255) NOT NULL DEFAULT 'incomplete', current_period_start, current_period_end, canceled_at, ended_at and trial_ends_at TIMESTAMPTZ NULL, cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE, created_at and updated_at TIMESTAMPTZ NULL. + - CHECK `golem15_pgsub_subscriptions_stripe_status_check` over the eight PHP statuses. + - FK `golem15_pgsub_subscriptions_subscription_plan_id_foreign` ON DELETE RESTRICT. + - Indexes: `golem15_pgsub_subscriptions_stripe_subscription_id_unique`, `_subscription_plan_id_index`, `_stripe_status_index`, `_current_period_end_index`, `pgsub_subscriber_index`, `pgsub_gateway_index`. +- **`golem15_pgsub_processed_webhook_events`:** + - Columns: id BIGSERIAL PK, event_id VARCHAR(255) NOT NULL, event_type VARCHAR(255) NULL, processed_at, created_at and updated_at TIMESTAMPTZ NULL. + - Indexes: `golem15_pgsub_processed_webhook_events_event_id_unique`, `_event_id_index`. + +**Migration `202610150002_add_stripe_customer_id_to_users`:** +- `ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255) NULL` +- `CREATE INDEX IF NOT EXISTS users_stripe_customer_id_index` +- The rollback is a no-op: the shared column may predate the plugin. + +**Deliberately absent:** the two dormant usage and featured-placement tables (D-16), the starter/professional/premium seed, the professionals-table ALTER and the professional_id to polymorphic conversion. + +**The one difference between the options:** the type of stripe_prices, currency_prices and features: TEXT under baseline-research, JSONB under baseline-jsonb (chosen). + +## Task 5: remote and push + +**Reply (verbatim):** "create-and-push" + +Before acting I checked the state: `main` at `aad4647`, clean tree, no remote, and `git ls-remote` answered "Cannot find repository". tea then created `golem15/sm-paymentgatewaysubscriptions-plugin` with admin permission. `origin` was added and `git push -u origin main` created the branch. **Pushed hash `aad4647dcdf4c792f9fef9d50f34db76e4c18272`**, and main equals origin/main. + +## Accomplishments + +- **Plugin.** `golem15.paymentgatewaysubscriptions` (package `pgsub`) requires `golem15.paymentgateway` and `golem15.user`. It embeds config and lang and runs two boot steps: the registries, then the service. +- **Models.** `Subscription` and `SubscriptionPlan` carry the PHP fillables, rules, scopes and checks. `ProcessedWebhookEvent` is the marker model. `FeatureList` stores a JSON list and also decodes the legacy truthy map. The eight statuses and two gateways use the verbatim PHP strings. +- **Contracts.** `Gateway`, `Subscriber` and `SubscriberResolver`, `CheckoutOptions` and `CheckoutSession`, and `Update`, `InvoiceUpdate` and `Invoice`. The `Subscribers` and `Gateways` registries come with their sentinel errors. +- **Events.** Six typed events carry the literal `golem15.pgsub.*` names. They fire inside the transaction with `Tx`. +- **Apply.** The PHP event diff runs in order (reactivated, then the status-unchanged stop, then activated, past_due, canceled). `ApplyDeleted` always fires canceled. `ApplyInvoice` fires with a PII-free invoice. `ClaimEvent` uses ON CONFLICT DO NOTHING, and `EventProcessed` is the cheap check. +- **Service API.** `ActivePlans`, `ActivePlanByCode`, `Latest`, `Active`, `HasActiveSubscription`, `OnPlan`, `CurrentPlanCode`, `Subscriber`, `Checkout`, `Portal`, `Cancel`, `HasAccess`, `InGracePeriod`, `HasFeature`, `SyncSubscription` and `SyncPlan`. +- **Config.** `grace_period_days` defaults to 7. A negative or non-numeric value fails boot. +- **Copy.** EN and PL lang files: plugin name and description, `statuses.*`, `gateways.*`, `errors.*`. +- **README.** Covers the API reference, configuration and the differences from PHP. It names no consuming application. + +## Exported identifiers + +- **pgsub:** + - `PluginID`, `ConfigGracePeriodDays` + - `Plugin`, with accessors `Service`, `Subscribers`, `Gateways` +- **contracts:** + - `Gateway` (Identifier, GetOrCreateCustomer, CreateCheckoutSession, CreateManagementSession, CancelSubscription, SyncSubscription, SyncPlanToGateway, SupportsCurrency); `CheckoutOptions{Gateway, Currency, Metadata}`; `CheckoutSession{URL, SessionID}` + - `Update`, `InvoiceUpdate`, `Invoice` + - `Subscriber`, `SubscriberResolver` + - `Subscribers`, `Gateways`, `NewSubscribers`, `NewGateways`; `HasSubscribables`, `HasSubscriptionGateways` + - errors `ErrUnknownSubscriberType`, `ErrDuplicateAlias`, `ErrDuplicateGateway`, `ErrGatewayNotInstalled`, `ErrSubscriberNotFound` +- **models:** + - `Subscription`, `SubscriptionPlan`, `ProcessedWebhookEvent`, `FeatureList` + - `StatusIncomplete` … `StatusPaused` and `Statuses`; `GatewayStripe`, `GatewayGooglePlay` and `Gateways`; `BillingIntervalMonth`, `BillingIntervalYear` + - `SubscriptionModelName`, `SubscriptionPlanModelName`, `ProcessedWebhookEventModelName` + - scopes `ForSubscriber`, `ScopeActive`, `ScopePastDue`, `ScopeCanceled`, `ScopeFromGateway`, `ScopeActivePlans`, `ScopeOrdered` + - model methods `IsActive`, `InGracePeriod`, `HasAccess`, `HasFeature`, `WillCancelAtPeriodEnd`, `DaysUntilRenewal`, `OnTrial`, `IsFromGateway`, `IsStripeSubscription`, `IsGooglePlaySubscription`, `PriceCentsFor`, `StripePriceIDFor`, `TierLevel`, `Rules`, `Fillable`, `MorphName` +- **events:** `SubscriptionActivated`, `SubscriptionCanceled`, `SubscriptionPastDue`, `SubscriptionReactivated` (embedding `SubscriptionEvent`); `InvoicePaid`, `InvoicePaymentFailed` (embedding `InvoiceEvent`); the six `...EventName` constants. +- **classes:** + - `Service` with the methods above, plus `Apply`, `ApplyDeleted`, `ApplyInvoice`, `ClaimEvent`, `EventProcessed`, `Gateway`, `Gateways`, `GracePeriodDays` and the `Now` field + - `New`, `Options`, `DefaultGracePeriodDays`, `ErrNoCustomer`, `UserSubscribable`, `UserAlias` +- **Signatures plan 03 relies on:** + - `ApplyDeleted(ctx, tx, gateway, subscriptionID) (*models.Subscription, error)` + - `ApplyInvoice(ctx, tx, contracts.InvoiceUpdate) (*models.Subscription, error)` + - `ClaimEvent(ctx, tx, eventID, eventType) (bool, error)` + - `EventProcessed(ctx, db, eventID) (bool, error)` + - `SyncPlan(ctx, tx, plan) (map[string]string, error)` + +## Tests + +All Docker tests ran on testcontainers Postgres 16. +- `go vet ./...` and `go test -short ./...` pass. `go list -deps ./...` contains no stripe-go. +- Task 2 verify passed (`TestSubscriptionsTracer`). The tracer gate re-ran the verify end to end before expanding. +- Task 3 verify: 2 of 2 pass (`TestSubscriptionsTracer`, `TestSubscriptionsCheckout`). +- Task 4 verify: 3 of 3 pass (`TestSubscriptionsCoreSmoke` with 13 subtests, the tracer and the checkout test). The full `go test ./...` passes. +- **JSONB:** `TestPlanJSONBColumns` checks: + - the round trip, with an unset value read back as SQL NULL; + - `jsonb_typeof` answers `object`; + - malformed JSON is rejected; + - the exact stripe_prices lookup (price_pln_1 does not match price_pln_10). +- **Acceptance greps:** + - 16 `IF NOT EXISTS` in the baseline; + - 0 dormant table names; + - 6 event names; + - the Requires line; + - no application names; + - `ON CONFLICT (event_id) DO NOTHING` present, 0 `LIKE`; + - 18 of 18 Service methods; + - no `FEATURE_` or `UnlimitedQuotes`; + - no `.Save(` in the user subscriber; + - `## API reference` present. + +## Deviations from Plan + +### Decisions and auto-fixed issues + +1. **[Task 1 decision] JSONB columns.** This departs from the RESEARCH TEXT DDL and the lagoon.Jsonable TEXT convention. No adaptation was needed: `lagoon.Jsonable[map[string]string]` and `[map[string]int64]` scan and write JSONB through pgx/GORM as they are, and `FeatureList` accepts `[]byte` and `string`. Proven on Postgres by TestPlanJSONBColumns and the `baseline_idempotent` data-type check. +2. **[Rule 2] Apply input checks.** Apply refuses a status or gateway outside the PHP value lists. It also checks the subscriber/plan pair on every save, not only on new rows, which matches PHP's model validation. +3. **Plan rules have no BeforeValidate fallback.** The lagoon model rule set covers every PHP rule: size:3 becomes `between:3,3`, `string` is implicit, and `exists` is left to the FK. Cabana CRUD maps a BeforeValidate error to a CapabilityError, not a field 422. I added `max:50` on code for the column width. +4. **Lang keys follow the plan.** They use `statuses.*` and `gateways.*`, as plans 04 and 05 expect. PHP used `status.*`. +5. **NULL ordering.** Latest and Active order by `created_at DESC NULLS LAST, id DESC`, matching MySQL's NULL ordering for PHP `latest()`. +6. **Additive identifiers.** `ErrSubscriberNotFound`, `ErrNoCustomer` (in classes), `GracePeriodDays()`, the plugin's registry accessors, the BillingInterval constants and the Statuses/Gateways slices. +7. **Which gateway a call uses.** Portal and SyncPlan use the `stripe` gateway (no gateway parameter). Cancel and SyncSubscription use `sub.Gateway`, falling back to stripe when empty. +8. **Test additions.** + - `TestPlanJSONBColumns`; + - the `acme.dupusers` duplicate-alias fixture; + - extra smoke subtests: `sync_plan_paid` (product id kept when a price step fails), `service_api` (including the legacy feature-map decode), `plan_rules`, and the grace env override (0 days, -1 fails boot). +9. **User subscriber name fallback.** The built-in `user` subscriber falls back to the e-mail, because the users table has no username column (RESEARCH Pattern 7). + +**Total deviations:** one decided change (JSONB), one Rule 2 addition and several additive identifiers. None changes scope. + +## Issues Encountered + +- `run()` first returned `gormigrate.MigrateFunc`, which a Rollback field cannot take. It now returns an unnamed `func(*gorm.DB) error`. +- A smoke subtest reused a loaded model in `First`, which added a second id condition. I fixed the test. + +## Known Stubs + +None. + +## Next Phase Readiness + +- Plan 03 can point `sm-pgstripe-plugin/subscriptions` at the committed local repository (replace) or at the pushed remote. The contracts it consumes are listed above. +- Plan 04 builds the admin screens and the customer API on `classes.Service`, the models' rules and the `statuses.*` and `gateways.*` copy. + +## Self-Check: PASSED + +- All 30 files exist in `../sm-paymentgatewaysubscriptions-plugin`, and the tree is clean. +- Commits `11a1730`, `5388ffb` and `aad4647` exist on `main`. `origin/main` = `aad4647dcdf4c792f9fef9d50f34db76e4c18272`.