docs(18.1): capture phase context

This commit is contained in:
2026-10-09 19:19:36 +02:00
parent 01a34f87f8
commit 9248b26f87
2 changed files with 292 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
# Phase 18.1: Payment Gateway Subscriptions - Context
**Gathered:** 2026-10-09
**Status:** Ready for planning
<domain>
## Phase Boundary
Port the generic core of `Golem15.PaymentGatewaySubscriptions` (about 5.5k LOC of PHP) to a new shared plugin, `sm-paymentgatewaysubscriptions-plugin`. Also port pgstripe's subscription parts (`StripeSubscriptionGateway`, `ProcessStripeSubscriptionWebhook`, `POST /webhook/stripe/subscriptions`), which Phase 18 left out (P18 D-01), into a subpackage of `sm-pgstripe-plugin`. The port covers:
- the `Subscription` and `SubscriptionPlan` models, with their statuses, scopes and access, grace-period and feature checks
- the gateway-neutral `SubscriptionGateway` contract, including `syncPlanToGateway`, with a Stripe implementation
- polymorphic subscribers through a build-time registry
- the six `golem15.pgsub.*` events
- the subscription webhook (its canonical URL and the legacy alias) processed through a conga job with idempotency
- the admin screens for subscriptions and plans
- a Go service API, a generic headless customer API, and subscription components added to `vue-paymentgateway-layer`
It is proven, as in P18 D-03, by the test host inside the plugin repo.
Not in this phase: Quotify's lead fees and metered billing, featured placements and quote analytics (they go to Quotify's own port), a Google Play gateway implementation, and the Horoskopia app port, including its own `plans` and `my/subscription*` routes.
</domain>
<decisions>
## Implementation Decisions
### Source and core scope
- **D-01:** **The frozen port contract is `paymentgatewaysubscriptions` at `ac3d73c` (Horoskopia's `develop`) plus commit `5fb9dac`.** That commit is on `origin/feature/qs-update`, used by QuestStream, and not merged. It adds `syncPlanToGateway(SubscriptionPlan): array<currency, priceId>` to the `SubscriptionGateway` contract. Read the source from `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions` and run `git show 5fb9dac` for the extra method. pgstripe stays frozen at `8b47457` (P18 D-02). The P15/P18 frozen-source rule applies: do not silently pick a newer tree or another app's copy (quotify.pro, drzewo.net and getaround.world are older). The binding parts are table and column names, event names, config keys, permission codes, the webhook URLs and the subscription status and gateway string values. — **Reversibility:** costly — table, event and key names are copied from this tree into every app's DB and code.
- **D-02:** **Port only the generic core.**
- **Ported:** Subscription, SubscriptionPlan, the `Subscribable` and `SubscriptionGateway` contracts, `HasSubscriptions` behavior (as a Go helper), ProcessedWebhookEvent, the events, the webhook, and the Subscriptions and SubscriptionPlans admin screens.
- **Not ported (they move to Quotify's port):** `LeadFeeSubscriber`, `MeterService`, `UsageRecord`, the `default_lead_fee_*` config keys, `FeaturedPlacement` and its admin controller, `AnalyticsService`, the `AnalyticsDashboard` component and `QuotaExceededException`.
- **Shared schema:** the tables those features use stay in the schema (D-04), so all apps keep one migration history.
- **D-03:** **Plan features are opaque string keys.** `Plan.HasFeature(key string)` and `Subscription.HasFeature(key)` work over the stored string list (the v2.4.0 shape). The plugin defines no `FEATURE_*` constants (they belong to QuestStream) and no `hasUnlimitedQuotes` (it belongs to Quotify). Apps declare their own keys in their own packages.
- **D-04:** **The migrations are one squashed baseline equal to the final PHP schema (v2.4.0).** It includes `golem15_pgsub_subscription_plans`, `golem15_pgsub_subscriptions` (polymorphic `subscriber_type`/`subscriber_id`, `gateway`), `golem15_pgsub_processed_webhook_events`, and the dormant `golem15_pgsub_usage_records` and `golem15_pgsub_featured_placements`. The baseline is idempotent so it adopts an existing PHP database unchanged, as in Phase 5's squashed migrations. Not carried over: the `starter`/`professional`/`premium` seed, the `golem15_quotify_professionals` ALTER and the `professional_id` → polymorphic conversion. The researcher diffs the baseline against a real PHP database, Horoskopia or QuestStream. — **Reversibility:** one-way — the migration ID and table shapes become the shared contract across five apps.
- **D-05:** **Google Play stays a valid gateway value without an implementation.** The `gateway` column, the `stripe` and `google_play` values and a gateway check (`IsFromGateway`) survive. Only Stripe implements `SubscriptionGateway`. A Google Play gateway would be a separate future plugin.
### Package layout and subscribers
- **D-06:** **New repo `sm-paymentgatewaysubscriptions-plugin`** (module `git.golem15.com/golem15/sm-paymentgatewaysubscriptions-plugin`, plugin ID `golem15.paymentgatewaysubscriptions`). It requires `golem15.paymentgateway` and `golem15.user`, as PHP's `$require` does. It holds the models, contracts, gateway registry, events, admin, service API and customer API. It contains no Stripe code.
- **D-07:** **The Stripe subscription gateway lives in a subpackage of `sm-pgstripe-plugin`** (for example `pgstripe/subscriptions`). The subpackage imports pgsub, implements `SubscriptionGateway`, and registers the webhook route and job. Apps that don't import the subpackage don't link pgsub, so a payments-only app keeps a payments-only binary. It reuses P18's stripe-go client, the pinned API version and the `httptest` backend override (P18 D-12). — **Reversibility:** costly — the import paths become what apps wire in their `plugins.go`.
- **D-08:** **Subscribers register through a build-time morph registry.** It succeeds the `subscribables` config and `Relation::morphMap`. An app registers a morph alias with a resolver that implements the Go `Subscribable` interface: email, name, get and set customer ID, morph alias and key. pgsub ships a ready `user` subscribable for `sm-user-plugin`, which an app opts into; Horoskopia stores `subscriber_type = 'user'`. Other types, such as Quotify's `professional` and QuestStream's `family`, are registered by their apps. An unknown `subscriber_type` read from the DB fails loudly; it is never silently skipped.
- **D-09:** **The built-in `user` subscribable stores the gateway customer ID in `users.stripe_customer_id`,** the column Horoskopia's `horoscope` plugin already added. A pgsub migration adds it only if missing (nullable, indexed, idempotent), so Horoskopia's DB adopts it unchanged. App-registered subscribables store the ID wherever their resolver says. — **Reversibility:** costly — a column on the shared `users` table.
### Webhook surface
- **D-10:** **Two URLs share one handler.** `POST /webhook/stripe/subscriptions` (pgstripe, canonical) and `POST /api/subscriptions/webhook` (the legacy pgsub alias) both reach the same handler, so no Stripe dashboard needs repointing at cutover. Both are CSRF-free and keep PHP's `throttle:60,1` limit. pgsub's deprecated `WebhookController`, `StripeSubscriptionService`, its own `ProcessStripeSubscriptionWebhook` job and `config/stripe.php` are not ported. Commit `0673aa7` already delegated them to pgstripe. — **Reversibility:** one-way — URLs configured in each app's Stripe dashboard.
- **D-11:** **The handler verifies the event and enqueues a conga (River) job.** It keeps PHP's shape and its STRIPE-006 fix:
1. Verify `Stripe-Signature`. A bad signature or payload returns 400 with a log entry.
2. Cheap-check `golem15_pgsub_processed_webhook_events`. A duplicate returns 200.
3. Enqueue jobs only for the five handled types: `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid` and `invoice.payment_failed`.
4. The job applies the change and inserts the processed-event marker in the same transaction, only on success. A failed job retries without being marked processed.
- **D-12:** **The subscription webhook signing secret is its own encrypted setting for each mode** (test and live), stored in P18's D-13 Stripe settings (`lagoon.Encrypted`, write-only in the admin) with the `SUMMER_*`/host-config fallback. It is separate from the payment-notification secret, because Stripe issues one secret per endpoint. A missing secret returns 500 with a log entry, as PHP does. App cutovers import the PHP values, `STRIPE_SUBSCRIPTION_WEBHOOK_SECRET`/`STRIPE_WEBHOOK_SECRET` or the PHP setting, through `lagoon.DecryptLaravelPayload` where they are encrypted.
### Customer API and frontend
- **D-13:** **pgsub ships a typed Go service API and a generic headless customer API.**
- **Service API:** active plans, current subscription for a subscriber, checkout session, cancel (at period end or immediately), management-portal session, and access and feature checks. App routes call it; Horoskopia's port keeps its own `plans`, `my/subscription`, `my/subscription/checkout`, `my/subscription/cancel` and `my/subscription/portal` routes, whose response shapes carry app data and are its parity contract.
- **Customer API:** a JSON API under `/_paymentgatewaysubscriptions/api/v1/`, following the `/_x/api/v1` convention (P15 D-14, P18 D-06), for the logged-in `user` subscriber. It succeeds Winter's `SubscriptionManager` component. Plans are public; the subscription endpoints require auth.
- **Reversibility:** one-way — a published API that apps and the layer consume.
- **D-14:** **`vue-paymentgateway-layer` (P18 D-06) gets the subscription pieces.** These are a typed subscriptions client plus components for a plans or pricing table, a my-subscription status, and checkout and portal redirects. They use the same distribution (a pnpm git dependency pinned to a tag), neutral CSS tokens and overridable components. There is no separate subscriptions layer repo.
### Claude's Discretion
- Go package, type and identifier names (registry, `Subscribable`, gateway interface, event types, service API), provided D-06 to D-09 hold. A short package name such as `pgsub` is fine; the plugin ID and table prefix stay as in D-01 and D-04.
- Typed festival event shapes for the six events (`golem15.pgsub.subscription.activated`, `.canceled`, `.past_due`, `.reactivated`, `golem15.pgsub.invoice.paid` and `.invoice.payment_failed`), keeping the PHP names as identifiers. Horoskopia listens to the four subscription events.
- The admin screens:
- Subscriptions list, filter and form.
- SubscriptionPlans list, form and reorder, with the bulk "Sync to Stripe" and "Sync all" actions going through `syncPlanToGateway`.
- Editing `currency_prices` and `stripe_prices`: a cabana repeater or key-value field, or P18's `money` field, whichever fits the existing field set.
- The `manage_placements` permission code: whether it stays registered or moves with FeaturedPlacement.
- Plan prices use P18's Money type (D-09 there) at the API boundary, while the stored JSON shapes (`currency_prices`, `stripe_prices`, `price_cents`) stay as PHP writes them.
- Config keys: `grace_period_days` (default 7) carries over; `subscribables` is replaced by the D-08 registry; the `default_lead_fee_*` keys go with D-02.
- The proof flows in the test host: checkout → `customer.subscription.created` → `activated` event; `invoice.paid`; `invoice.payment_failed` → `past_due` and the grace period; cancel at period end → `deleted` → `canceled`; reactivation; duplicate-event idempotency; plan sync. Map pgstripe's subscription-related `tests/` (reliability, security/DataHandling) to Go cases in the last plan.
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan".
- Run the security-review agent: the phase handles payments, secrets, a public webhook and a customer API.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Roadmap and decisions
- `.planning/ROADMAP.md` § Phase 18.1 and § Phase 18: goal, repos and splits
- `.planning/phases/18-payment-gateway/18-CONTEXT.md`: D-01 (this split), D-02 (frozen pgstripe `8b47457`), D-03 (test host), D-06 (customer API and `vue-paymentgateway-layer`), D-09/D-10 (Money), D-12 (stripe-go and its test fake), D-13 (encrypted Stripe settings per mode), D-14 (webhook verification behavior)
- `.planning/notes/core-plugin-port-inventory.md`: the five subscriptions users and Horoskopia's use of `Subscription`, `SubscriptionPlan` and `SubscriptionGateway`
- `.planning/notes/core-plugins-own-repos.md`: the `sm-<name>-plugin` repo, module path, submodule and `go.work` layout
- `.planning/research/PITFALLS.md` #5: exact money, never `float64`
- `.planning/phases/15-journal-plugin/15-CONTEXT.md`: D-01/D-02 (frozen source), D-05 (PHP tests as the spec), D-06 (EN/PL), D-14 (`/_x/api/v1`)
- `.planning/seeds/SEED-261007-j2b-horoskopia-port.md`: the later Horoskopia port that consumes this plugin
### PHP port sources (frozen per D-01)
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/`:
- `Plugin.php` (permissions, navigation, morph map)
- `contracts/Subscribable.php`, `contracts/SubscriptionGateway.php` (plus `git show 5fb9dac`)
- `models/Subscription.php`, `models/SubscriptionPlan.php`, `models/ProcessedWebhookEvent.php`
- `models/*/fields.yaml`, `models/*/columns.yaml`
- `traits/HasSubscriptions.php`
- `controllers/Subscriptions.php`, `controllers/SubscriptionPlans.php` and their YAML
- `http/routes.php` (the legacy alias)
- `config/config.php`, `lang/en/lang.php`
- `updates/` (schema history for the D-04 baseline), `MIGRATION.md` (the polymorphic model)
- Skip the files D-02 moves to Quotify.
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe/`: `classes/StripeSubscriptionGateway.php`, `jobs/ProcessStripeSubscriptionWebhook.php`, `http/routes.php`, `http/controllers/StripeWebhookController.php`, `classes/StripeSettings.php` (`getWebhookSecret`), `tests/reliability/`, `tests/security/DataHandlingTest.php`
- `/media/nvme/dev/golem15/queststream.online/plugins/golem15/queststream/services/EntitlementService.php`: a second consumer of `syncPlanToGateway` and the gateway field (read-only reference)
### Acceptance consumer (read-only reference)
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/routes.php` (around lines 364–760): the `plans` and `my/subscription*` routes that call `Subscription::forSubscriber`, `SubscriptionPlan` and the `SubscriptionGateway` binding
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/classes/SubscribableUserAdapter.php`: the `user` subscribable and `users.stripe_customer_id` (D-08, D-09)
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/Plugin.php` (around lines 335–480): the User relation extension and the four `golem15.pgsub.subscription.*` listeners
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/horoscope/console/ReconcileSubscriptions.php`: uses `syncSubscription` and the status constants
- `/media/nvme/dev/golem15/quotify.pro/themes/quotify/pages/subscription.htm`: the `subscriptionManager` component usage that D-13's customer API succeeds
### Framework
- `modules/conga/README.md`: River jobs for D-11
- `modules/festival/README.md`: typed events
- `modules/lagoon/encrypted.go`, `modules/lagoon/laravel_decrypt.go`: D-12
- `modules/pact/`: the capability pattern for gateway and subscriber registration
- `modules/cabana/README.md`: admin lists, forms and reorder, bulk actions, settings pages
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- From Phase 18 (once built): the stripe-go client and its `httptest` fake, the encrypted Stripe settings page with a test/live switch, the Money type, the operator registry pattern (a model for the gateway and subscriber registries), and `vue-paymentgateway-layer`.
- `conga`: River jobs for webhook processing.
- `festival.Bus`: the `golem15.pgsub.*` events.
- `sm-user-plugin`: the `users` table and the authenticated customer behind the built-in `user` subscribable.
- `sm-journal-plugin/routes.go`: the `/_x/api/v1` route, limiter and CORS pattern.
### Established Patterns
- Core plugins keep PHP table names, columns, permission codes, event names and config keys.
- Squashed, idempotent baseline migrations that adopt existing PHP databases (Phase 5).
- Framework and plugin READMEs never name a consuming application.
### Integration Points
- A new sibling checkout `/media/nvme/dev/golem15/summercms.io/summercms/sm-paymentgatewaysubscriptions-plugin/`. Confirm the remote exists before the first push.
- A new subpackage in `sm-pgstripe-plugin` (Phase 18's repo).
- New subscription pieces in `vue-paymentgateway-layer` (Phase 18's repo).
- `summercms.go` only if a framework feature is missing, for example cabana bulk list actions for the plan sync.
- Phase 18 must be executed first: 18.1 builds on its pgstripe, Money, settings and layer.
</code_context>
<specifics>
## Specific Ideas
- Horoskopia's flow: `Subscription::forSubscriber('user', $id)->latest()` gives the current subscription. Checkout goes through `app(SubscriptionGateway::class)->createCheckoutSession(adapter, plan, success, cancel)` and the frontend redirects. `createManagementSession` opens the Stripe billing portal. The Go service API must keep this sequence just as short.
- `syncPlanToGateway` returns a currency → price ID map and stores the provider IDs on the plan (`stripe_product_id`, `stripe_prices`).
- The idempotency marker is written only after the job succeeds (STRIPE-006). Keep that ordering.
</specifics>
<deferred>
## Deferred Ideas
- **Quotify-specific subscription features** (lead fees and Stripe Billing Meters via `LeadFeeSubscriber`/`MeterService`/`UsageRecord`, `FeaturedPlacement`, quote analytics via `AnalyticsService`/`AnalyticsDashboard`, and `QuotaExceededException`) go to Quotify's own port. Their tables already exist through D-04.
- **A Google Play subscription gateway** would be a future plugin implementing `SubscriptionGateway`.
- **A generic admin subscription dashboard** (MRR, churn, active counts) is a new capability. Revisit it after the Horoskopia port, when real data exists.
### Reviewed Todos (not folded)
- `mailblocker-with-mailing.md`, `refresh-fonoteka-readme.md`, `faq-plugin-port.md`, `journal-imported-html-admin-resave.md`: these matched on keywords only and are unrelated to subscriptions.
</deferred>
---
*Phase: 18.1-payment-gateway-subscriptions*
*Context gathered: 2026-10-09*

View File

@@ -0,0 +1,124 @@
# Phase 18.1: Payment Gateway Subscriptions - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-10-09
**Phase:** 18.1-payment-gateway-subscriptions
**Areas discussed:** Source and core scope, Package layout and subscribers, Webhook surface, Customer API and frontend
Phase 18.1 was inserted into the roadmap at the start of this session (`phase.insert 18`), as 18-CONTEXT D-01 asked.
---
## Source and core scope
| Option | Description | Selected |
|--------|-------------|----------|
| ac3d73c + 5fb9dac | Horoskopia develop plus QuestStream's unmerged `syncPlanToGateway` | ✓ |
| ac3d73c only | Strictly Horoskopia's checkout | |
| Merge all five copies | Union of every app's copy | |
| Option | Description | Selected |
|--------|-------------|----------|
| Generic core only | Lead fees, placements and analytics go to Quotify's port; their tables stay | ✓ |
| Port everything 1:1 | One plugin, all features | |
| Core + metering | Keep metered billing as a generic capability | |
| Option | Description | Selected |
|--------|-------------|----------|
| Opaque string keys | `HasFeature(key)`, no constants | ✓ |
| App-registered feature catalogue | Keys plus labels registered at build time | |
| Keep constants 1:1 | Port the `FEATURE_*` constants | |
| Option | Description | Selected |
|--------|-------------|----------|
| Squashed v2.4.0 baseline | Final schema including dormant tables; idempotent; no seed, ALTER or conversion | ✓ |
| Replay the PHP chain | One Go migration per PHP file | |
| Baseline without dormant tables | Quotify creates its tables itself | |
| Option | Description | Selected |
|--------|-------------|----------|
| Keep constant, no impl | `google_play` stays valid; only Stripe is implemented | ✓ |
| Drop it | Only `stripe` is valid | |
---
## Package layout and subscribers
| Option | Description | Selected |
|--------|-------------|----------|
| pgstripe subpackage | pgsub in its own repo; Stripe gateway in a `sm-pgstripe-plugin` subpackage | ✓ |
| Inside pgsub | pgsub imports pgstripe | |
| Third repo | `sm-pgstripe-subscriptions-plugin` | |
| Option | Description | Selected |
|--------|-------------|----------|
| Build-time registry, user built in | Morph alias plus resolver; opt-in `user` subscribable | ✓ |
| Registry only, nothing built in | Every app writes its own adapter | |
| Hard-wire to user | Drop polymorphism | |
| Option | Description | Selected |
|--------|-------------|----------|
| users.stripe_customer_id | Horoskopia's column, added idempotently | ✓ |
| pgsub mapping table | New `golem15_pgsub_customers` | |
| Built-in user leaves it to the app | Column name from config | |
---
## Webhook surface
| Option | Description | Selected |
|--------|-------------|----------|
| Both URLs, one handler | Canonical pgstripe URL plus the legacy pgsub alias | ✓ |
| Canonical only | Repoint the legacy endpoint at cutover | |
| Merge into _paymentgateway route | New URL everywhere | |
| Option | Description | Selected |
|--------|-------------|----------|
| Verify + enqueue conga job | Marker written in the job transaction after success | ✓ |
| Synchronous in the handler | One transaction in the request | |
| Option | Description | Selected |
|--------|-------------|----------|
| Own setting per mode | A separate encrypted subscription signing secret for test and live | ✓ |
| Reuse the P18 secret | One secret for both endpoints | |
---
## Customer API and frontend
| Option | Description | Selected |
|--------|-------------|----------|
| Go service API + headless /_x/api/v1 | Horoskopia keeps its own routes; new apps use the generic API | ✓ |
| Go service API only | No HTTP routes in the plugin | |
| Headless API replaces app routes | Breaks frontend parity | |
| Option | Description | Selected |
|--------|-------------|----------|
| Extend vue-paymentgateway-layer | Subscription client and components in the P18 layer | ✓ |
| Separate vue-pgsub-layer | Own repo | |
| No layer in 18.1 | API only | |
| Option | Description | Selected |
|--------|-------------|----------|
| Yes, Quotify port | AnalyticsDashboard not ported | |
| Port a generic subscription dashboard | Admin MRR/churn widget | ✓ (then deferred) |
**Notes:** The user chose a generic dashboard. It was flagged as a new capability, and the user then placed it in Deferred Ideas instead of 18.1 scope.
---
## Claude's Discretion
- Go names for the registries, interfaces, events and service API
- Typed festival event shapes for the six `golem15.pgsub.*` events
- Admin plan editing (`currency_prices`, `stripe_prices`), the reorder and the Sync to Stripe bulk actions, and the `manage_placements` permission code
- The test-host proof flows and mapping pgstripe's tests to Go cases
- Plan count and split
## Deferred Ideas
- Quotify lead fees and metering, featured placements and quote analytics: Quotify's port
- A Google Play subscription gateway plugin
- A generic admin subscription dashboard (MRR, churn), after the Horoskopia port