docs(18.1): research phase domain
This commit is contained in:
@@ -0,0 +1,947 @@
|
||||
# Phase 18.1: Payment Gateway Subscriptions - Research
|
||||
|
||||
**Researched:** 2026-10-14
|
||||
**Domain:** Port of `Golem15.PaymentGatewaySubscriptions` (generic core) and pgstripe's subscription gateway, webhook and job to SummerCMS shared plugins (Go, Postgres, River, stripe-go v87, Nuxt layer)
|
||||
**Confidence:** HIGH for the PHP contract, schema and Phase 18 reuse (all read this session); MEDIUM for Stripe API-version behaviour; LOW only where marked `[ASSUMED]`
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked 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.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- **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.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No REQUIREMENTS.md IDs are mapped to this phase. The ROADMAP success criteria and the CONTEXT decisions are the requirement set; the planner should key plans and verification to these IDs.
|
||||
|
||||
| ID | Description (ROADMAP 18.1 success criteria) | Research support |
|
||||
|----|----------------------------------------------|------------------|
|
||||
| SC-1 | Subscription and SubscriptionPlan on a squashed, idempotent v2.4.0 baseline that adopts existing PHP databases; polymorphic subscribers registered at build time; built-in `user` subscribable | § v2.4.0 Schema Baseline (DDL diffed against a real PHP dump), § Premise shifts S-2/S-3, Pattern 2 (subscriber registry), Pattern 7 (user subscribable) |
|
||||
| SC-2 | `SubscriptionGateway` (incl. `syncPlanToGateway`) with a Stripe implementation; `POST /webhook/stripe/subscriptions` and the legacy alias verify, enqueue, process idempotently, fire the six `golem15.pgsub.*` events | § Premise shift S-1 (sync source), § Event and State Mapping, Patterns 3–6, Code Examples |
|
||||
| SC-3 | Subscriptions and SubscriptionPlans admin screens, Go service API, headless customer API, subscription components in `vue-paymentgateway-layer` | § Admin Screens, § Consumer → Service API map, § Customer API, § Layer additions |
|
||||
| SC-4 | Host test proves checkout, activation, `invoice.paid`, `past_due` + grace, cancel and duplicate-event idempotency against a Stripe fake | § Validation Architecture (host test in `sm-pgstripe-plugin/subscriptions`), Pitfall 9 (fake not importable) |
|
||||
| SC-5 | Unit tests for the new code in the last plan | § PHP tests → Go cases, § Validation Architecture Wave 0 |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
From `summercms.go/CLAUDE.md`, the meta-repo `CLAUDE.md` and the user's global `CLAUDE.md`:
|
||||
|
||||
- **Postgres only.** GORM on Postgres; no MySQL support except the Phase 20 import path.
|
||||
- **YAML via `goccy/go-yaml`**, never `gopkg.in/yaml.v3`.
|
||||
- **Standard library first; a new dependency needs a research doc or decision note.** This phase needs no new dependency (stripe-go v87 is already named by P18 D-12).
|
||||
- **Core plugins must not get breaking changes** (user, paymentgateway, pgstripe are core). Every change to `sm-pgstripe-plugin` root and `sm-user-plugin` must be additive.
|
||||
- **Framework code and plugin READMEs never name a consuming application.** The PHP job's hard-coded `'queststream'` must become configuration (Premise shift S-5).
|
||||
- **No runtime plugin loading**; plugins are compiled in and registered through `party.Register` in `init()`.
|
||||
- **README rules:** a change to exported API, config keys, CLI commands or dependencies updates the module/plugin README and `docs/` in the same commit; every identifier in a README must exist (docs checker); README structure H1, summary, import line, `**Name:**`, Overview, Features, Usage, API reference, Configuration/CLI, Dependencies, Testing.
|
||||
- **Docker-backed tests skip under `-short`**; `TestMain` must `flag.Parse()` and use `testing.Short()`. Keep `go vet` and `go test ./...` green at every commit.
|
||||
- **MSD rules:** lean planning (few, large plans; present the plan count with one-line scopes and wait for confirmation); **unit tests are the last plan**; update STATE.md through `msd_run query state.*`; post a g15office comment when the phase advances.
|
||||
- **Commits:** no co-author tags; one logical change per commit; planning docs and code in separate commits; stage files by explicit path, never `git add -A`; several sessions work concurrently. **Plugin first, then the pointer bump** in an app as a separate commit. **No push, tag or deploy without the user's go-ahead.**
|
||||
- **Sibling layout:** consumers `require` a tagged framework and `replace` it with `../summercms.go`; shared plugins live in `sm-*-plugin` repos (`notes/core-plugins-own-repos.md`); plugin layout mirrors Winter directories, `models/` is a leaf package (`notes/plugin-layout-winter-directories.md`).
|
||||
|
||||
## Summary
|
||||
|
||||
The PHP side is small and well understood: two models with eight Stripe statuses, a five-type webhook that enqueues a job, a job that maps Stripe objects to rows and fires six string events, and a gateway contract with nine methods. Phase 18 delivered almost everything the port leans on: the stripe-go v87 client factory with its `BackendOverride` (`sm-pgstripe-plugin/client.go`), signature-only verification that tolerates older endpoint API versions (`pgstripe.VerifySignature`), the encrypted write-only Stripe settings with config fallbacks, the `HasOperators`-over-`party.Activated` registry pattern, typed festival events that fire inside the transaction with listeners deferring side effects via `lagoon.AfterCommit`, cabana bulk/toolbar/record actions, preview-only controllers, plugin field types, and a customer-API auth/throttle pattern. No framework change is strictly required.
|
||||
|
||||
Several CONTEXT.md premises no longer hold or were incomplete, and the planner must resolve them before writing plans. The most important: **pgstripe at `8b47457` does not implement `syncPlanToGateway`** — the Stripe implementation is in `ec2bd34`/`d00f850` on `feature/qs-update`, merged in `d54dcde`, which is the current HEAD of the Horoskopia checkout. **Every PHP app runs MariaDB**, so "an idempotent baseline that adopts an existing PHP database unchanged" cannot happen in place; adoption is the Phase 20 `summer import:winter` runner (not built yet). The **dormant usage/featured tables exist only where Quotify is installed** and carry FKs to Quotify tables, so they cannot be created faithfully in a pgsub baseline. In Horoskopia, **PHP's `stripe.webhookSecret` holds the subscriptions endpoint's secret**, but P18's `pgstripe:import-winter` maps it to the payment-notification column. Cabana's **reorder is tree-only** and there is **no `taglist`/`readOnly`**, so the PHP plan form and reorder page need adaptation.
|
||||
|
||||
Two Stripe API-version facts change the job's decoding: since `2025-03-31.basil` an invoice references its subscription at `parent.subscription_details.subscription` (the PHP job reads only the old top-level `subscription`, so `golem15.pgsub.invoice.*` likely never fires on a basil-versioned endpoint), and pre-basil endpoints still send `current_period_*` on the subscription top level rather than on items. The Go job should read both shapes.
|
||||
|
||||
**Primary recommendation:** Build pgsub as a gateway-neutral core whose `Service.Apply(ctx, tx, update)` owns state-change and event logic; put all Stripe mapping, the two webhook routes, the job and `SyncPlanToGateway` (ported from `d00f850`) in a `golem15.pgstripesubscriptions` plugin at `sm-pgstripe-plugin/subscriptions`; make the job claim the processed-event row first inside its transaction (`INSERT … ON CONFLICT DO NOTHING`) so STRIPE-006/007 hold and concurrent duplicates serialize; prove it with a host test inside that subpackage. Five plans, no framework plan.
|
||||
|
||||
## Premise Shifts (CONTEXT.md vs what Phase 18 and the frozen trees actually contain)
|
||||
|
||||
Each row is a decision the planner must take to the user at the plan-count checkpoint, or record as an amendment.
|
||||
|
||||
| # | CONTEXT premise | What is actually there | Recommendation |
|
||||
|---|-----------------|------------------------|----------------|
|
||||
| S-1 | D-01: pgstripe frozen at `8b47457`; `syncPlanToGateway` comes from pgsub `5fb9dac` | `git grep syncPlanToGateway 8b47457` in pgstripe finds nothing. The Stripe implementation (`createOrUpdateStripePrice`, `syncPlanToGateway`) plus the success-URL separator fix and the `UnexpectedValueException` catch arrive in `ec2bd34` + `d00f850`, merged as `d54dcde` ("Merge feature/qs-update: gateway plan sync and Stripe helper hardening"). The Horoskopia checkout HEAD **is** `d54dcde`; `git diff 8b47457 HEAD` touches only `classes/StripeSubscriptionGateway.php` (+123/−2) `[VERIFIED: git log/diff in pgstripe this session]`. pgsub's checkout HEAD is `a522376` = merge of `ac3d73c` + `5fb9dac`; `git diff ac3d73c HEAD` touches only `contracts/SubscriptionGateway.php` (+11) `[VERIFIED: git diff this session]` | **Amend D-01:** read `StripeSubscriptionGateway` from `d00f850` (= HEAD `d54dcde`); every other pgstripe file stays at `8b47457`. Read pgsub from the working tree (= `ac3d73c` + `5fb9dac`). Both checkouts need `git -c safe.directory=*` (dubious-ownership error) |
|
||||
| S-2 | D-04: "idempotent so it adopts an existing PHP database unchanged, as in Phase 5's squashed migrations" | All PHP apps are MariaDB (`DB_CONNECTION=mysql` in Horoskopia's `.env.example`; the dumps are MariaDB 12). Go is Postgres-only. Phase 20 CONTEXT D-01/D-02 decides that adoption is a Go import command (`summer import:winter`) with a per-plugin capability (working name `HasWinterImport`) — neither exists yet. Phase 5 D-01 baselines and all Phase 18 baselines are plain `CREATE TABLE` (not idempotent) | Keep the baseline idempotent (`IF NOT EXISTS`, cheap, harmless) but state the real contract: **same table/column/index names and compatible types so the Phase 20 runner can copy rows**. The `HasWinterImport` mapper for pgsub is deferred to Phase 20 / the Horoskopia port |
|
||||
| S-3 | D-04: the baseline includes dormant `golem15_pgsub_usage_records` and `golem15_pgsub_featured_placements` | PHP creates both **only when `golem15_quotify_professionals` exists** (`v1.0.3`, `v1.0.4` early-return), with FKs to `golem15_quotify_professionals` and `golem15_quotify_trade_categories`, and v2.0.0 never converted their `professional_id` to polymorphic. The real Horoskopia dump has **neither table** `[VERIFIED: grep of v2.1-pre-migrate dump]` | **Omit both from the pgsub baseline** (amend D-04). Creating them everywhere would make Horoskopia's Go schema differ from its PHP schema, and their FKs cannot be created without Quotify. Quotify's port creates them (or a later pgsub migration gated on Quotify). If the user keeps D-04, create them without the two Quotify FKs and document the divergence |
|
||||
| S-4 | D-12: cutover imports `STRIPE_SUBSCRIPTION_WEBHOOK_SECRET`/`STRIPE_WEBHOOK_SECRET` or the PHP setting | PHP pgstripe's subscription controller reads `StripeSettings::getWebhookSecret()` = `stripe.testWebhookSecret`/`stripe.webhookSecret`, falling back to `config('golem15.pgstripe::webhook_secret')` (`STRIPE_WEBHOOK_SECRET`). `STRIPE_SUBSCRIPTION_WEBHOOK_SECRET` is read only by the unported deprecated `WebhookController`. Horoskopia's own controller states "The PgStripe settings helper returns the SUBSCRIPTIONS endpoint's secret". P18's `pgstripe:import-winter` maps `stripe.webhookSecret` → `webhook_secret` (the **payment-notification** column) | Add `subscription_webhook_secret`/`test_subscription_webhook_secret` columns (P18's `updates/registry.go` comment already reserves "Phase 18.1 adds the subscription columns") and extend `pgstripe:import-winter` with an explicit target (`--webhook-secret-for=payments|subscriptions|both`, default `subscriptions` when pgsub is in use is the planner's call). Document in the README that on PHP one secret served both routes |
|
||||
| S-5 | D-01 binds names but not behaviour | The job skips any event whose `metadata.app` is set and not `'queststream'`, and the Stripe gateway writes `'app' => 'queststream'` into `subscription_data.metadata`; it also writes `billing_owner_id` when the subscriber has one (QuestStream-only) | Make the app tag a config key on the Stripe subpackage (e.g. `golem15.pgstripesubscriptions.metadata_app`, default empty = write nothing, filter nothing). Horoskopia's existing Stripe subscriptions carry `app=queststream`, so its cutover sets the key to that value. Drop `billing_owner_id` from the generic gateway (an app can add metadata through a `CheckoutOptions.Metadata` map) |
|
||||
| S-6 | Discretion: "SubscriptionPlans list, form and reorder" | cabana reorder is tree-only: `parentColumn` defaults to `parent_id` and must be a record column (`reorder.go:127-142`); plans have no parent column, so a `config_reorder.yaml` fails boot | Do not ship the reorder page in 18.1: `sort_order` is a number field on the form and the list's default sort (PHP list already sorts by `sort_order`). Capture "flat (Sortable) reorder in cabana" as a framework todo. A framework plan for it is the alternative if the user wants the page now |
|
||||
| S-7 | Discretion: the plan form edits `currency_prices` and `stripe_prices` | The frozen `models/subscriptionplan/fields.yaml` edits **neither** (nor `stripe_product_id`); it edits `features` with `type: taglist, mode: array`. cabana has no `taglist` and no `readOnly` key (`form_schema.go:24-52`); list columns accept only `label, searchable, sortable, type, relation, select, invisible, currency, currencyFrom` (no `valueFrom`, `default`, `cssClass`, `format`) | Port the PHP form faithfully minus unsupported keys; `features` through a pgsub plugin field type (Pattern 9); add read-only display of `stripe_product_id`/`stripe_prices`/`currency_prices` on a preview or a partial only if the user wants it. `currency_prices` editing is new scope — defer unless asked |
|
||||
| S-8 | D-07: subpackage reuses P18's `httptest` fake | P18's fake is `stripefake_test.go` in package `pgstripe_test`; a `_test.go` file is not importable from `sm-pgstripe-plugin/subscriptions` | Create a small importable fake for the subscription endpoints (e.g. `sm-pgstripe-plugin/internal/stripefake`, or a `_test.go` fake inside `subscriptions/`). Leave P18's root fake unchanged |
|
||||
| S-9 | D-07: "the import paths become what apps wire in their `plugins.go`" | Apps wire plugins in `summer.yaml` (`id` + `module`), and `summer build` emits `_ "<module>"` (`internal/build/build.go:146`). The plugin ID must match `^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$` (`manifest.go:16`): no third segment, no underscore | The subpackage is its own party plugin, e.g. `id: golem15.pgstripesubscriptions`, `module: git.golem15.com/golem15/sm-pgstripe-plugin/subscriptions`. A blank import of a subpackage path works because only the module root needs a `require` |
|
||||
| S-10 | CONTEXT "Phase 18 must be executed first" | Done and verified (`18-VERIFICATION.md`: human_needed, 11/11). Sibling heads: paymentgateway `f55320a`, pgstripe `0313820` (no tag), layer `ac2b960` (tag `v0.1.0` one test commit behind), framework tag `v0.1.25`. pgstripe's `go.mod` still requires framework `v0.1.24` via `replace` | Nothing blocks. New repo needs `tea repos create --owner golem15` (the way P18 created its repos) — `git ls-remote …/sm-paymentgatewaysubscriptions-plugin.git` says "Cannot find repository" |
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Plans, subscriptions, processed-event markers | Database / Storage (pgsub tables) | API / Backend (pgsub models) | PHP table contract; rows are the source of truth for access checks |
|
||||
| Access, grace-period and feature checks | API / Backend (pgsub models + service) | — | Pure functions over a row plus `grace_period_days`; never in the browser |
|
||||
| Gateway contract and registry | API / Backend (pgsub) | — | Gateway-neutral; Stripe code must not live in pgsub (D-06) |
|
||||
| Stripe calls (customer, checkout, portal, cancel, retrieve, product/price sync) | API / Backend (pgstripe/subscriptions) | External: Stripe API | Reuses P18 client factory and pinned SDK |
|
||||
| Webhook verification, cheap-check, enqueue | API / Backend (pgstripe/subscriptions HTTP handler) | — | Public, CSRF-free route; the HMAC is the only gate |
|
||||
| Applying Stripe state, firing `golem15.pgsub.*` | API / Backend (River job → pgsub `Service.Apply`) | Database (one transaction) | STRIPE-006: change + marker in one tx |
|
||||
| Subscriber identity (email, name, customer id) | API / Backend (resolver per morph alias) | Database (`users.stripe_customer_id` for `user`) | D-08/D-09 |
|
||||
| Admin screens, plan sync actions | API / Backend (cabana YAML + bulk/toolbar actions in pgsub) | Browser (admin SPA, unchanged) | Sync goes through the registry, so pgsub never imports Stripe |
|
||||
| Customer API (`/_paymentgatewaysubscriptions/api/v1`) | API / Backend (pgsub) | — | JWT via the user plugin's `jwt` guard; redirect URLs from config |
|
||||
| Pricing table, my-subscription, redirects | Frontend Server (SSR) / Browser (`vue-paymentgateway-layer`) | — | Layer calls the customer API; never holds secrets or prices of its own |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (all already in the tree; nothing new to install)
|
||||
|
||||
| Library | Version | Purpose | Why standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `github.com/stripe/stripe-go/v87` | v87.0.0 (pins API `2026-09-30.endive`) | Stripe client for the subscription gateway | Named by P18 D-12; pinned by `TestSDKPin`; API version read from `api_version.go:10` `[VERIFIED: module cache this session]` |
|
||||
| `github.com/stripe/stripe-go/v87/webhook` | same | `ValidatePayload` signature check (via `pgstripe.VerifySignature`) | P18 F-4: `ConstructEvent` rejects other release trains |
|
||||
| `git.golem15.com/golem15/summercms/modules/conga` | framework v0.1.25 | River job for the webhook (`conga.Job`, `Manager.Enqueue`) | Project job system |
|
||||
| `…/modules/festival` | same | Six typed events | Project event bus |
|
||||
| `…/modules/lagoon` | same | `Encrypted`, `Jsonable[T]`, `AfterCommit`, `DecryptLaravelPayload`, `Migrate` | Project data layer |
|
||||
| `…/modules/cabana`, `pact`, `party`, `surf`, `bouncer`, `wire`, `phrasebook` | same | Admin, capabilities, plugin lifecycle, routes/throttle, JWT, JSON, i18n | Project framework |
|
||||
| `git.golem15.com/golem15/sm-paymentgateway-plugin` | `f55320a` (no tag; `replace`) | `money` package for API-boundary prices; required plugin | D-06 `$require` |
|
||||
| `git.golem15.com/golem15/sm-user-plugin` | v0.1.1 (`replace ../sm-summercmsio-app/plugins/golem15/user`) | `users` table, `jwt` guard, `models.User` | D-08/D-09 |
|
||||
| `github.com/go-gormigrate/gormigrate/v2` | v2.1.7 | Migrations | Project standard |
|
||||
| `testcontainers-go` + `modules/postgres` | v0.44.0 | Docker-backed tests | Project standard |
|
||||
|
||||
### Frontend (layer; nothing new)
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `nuxt` (peer) | 4.4.8 | Layer host |
|
||||
| `@nuxtjs/i18n` | 10.4.0 | `pg.*` strings EN/PL |
|
||||
| `node --test` | Node ≥ 22.18 | Layer specs (`tests/*.test.ts`) |
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
| Instead of | Could use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| `V1Prices.List` with `LookupKeys` + `Active` | `V1Prices.Search` (what PHP uses) | Search is eventually consistent (a re-sync shortly after a create can miss the new price and create a duplicate); List by lookup key is immediate and exact. Same outcome otherwise. Recommend List `[CITED: stripe-go v87 price.go PriceListParams.LookupKeys]`; the eventual-consistency claim is `[ASSUMED]` |
|
||||
| Payload-driven `syncSubscription` (PHP) | Re-fetch the subscription from Stripe in the job | Re-fetch removes out-of-order regressions but adds an API call per event and changes behaviour; keep payload-driven for parity (Pitfall 4) |
|
||||
|
||||
**Installation:** none. `go mod tidy` in the new repo pulls only modules already used by the siblings.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
This phase installs no new external package. All Go modules above are already required by `sm-pgstripe-plugin`/`sm-paymentgateway-plugin` `go.mod` (read this session) and were audited in Phase 18 (`18-RESEARCH.md` § Package Legitimacy Audit). The layer adds no npm dependency.
|
||||
|
||||
| Package | Registry | Verdict | Disposition |
|
||||
|---------|----------|---------|-------------|
|
||||
| (none new) | — | — | — |
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none
|
||||
**Packages flagged as suspicious [SUS]:** none
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────── Stripe ───────────────┐
|
||||
│ Checkout / Billing Portal / API │
|
||||
└──┬───────────────▲──────────────▲─────┘
|
||||
signed events │ │ API calls │ redirect
|
||||
▼ │ │
|
||||
POST /webhook/stripe/subscriptions ─┐ │ │
|
||||
POST /api/subscriptions/webhook ────┤ │ │
|
||||
(throttle bucket 60/min/IP, │ │ │
|
||||
no CSRF, no auth) ▼ │ │
|
||||
[pgstripesubscriptions.Handler] │
|
||||
1 secret for mode? no → 500 + log │
|
||||
2 VerifySignature? no → 400 + log │
|
||||
3 decode minimal event; marker exists? → 200 │
|
||||
4 type in the five? no → 200 (no job) │
|
||||
5 conga.Enqueue(args: id, type, minimal obj) → 200
|
||||
│ │
|
||||
▼ River (queue) │
|
||||
[pgstripesubscriptions job] │
|
||||
tx: claim marker (ON CONFLICT DO NOTHING) ──┐│
|
||||
0 rows → already processed, commit ││
|
||||
app-tag filter skip → commit (marker kept, STRIPE-007)
|
||||
map Stripe object → pgsub.Update ││
|
||||
pgsub.Service.Apply(ctx, tx, update) ───►│ golem15_pgsub_* rows
|
||||
├─ upsert subscription (by gateway sub id)
|
||||
├─ diff previous status / cancel flag
|
||||
└─ festival.Fire(SubscriptionActivated|PastDue|Canceled|Reactivated|InvoicePaid|InvoicePaymentFailed{Tx})
|
||||
listeners: write in Tx, side effects via lagoon.AfterCommit
|
||||
error anywhere → rollback (marker gone) → River retry
|
||||
│
|
||||
App routes / customer API ──► pgsub.Service ─► registry.Gateway("stripe") ─► Stripe (checkout, portal, cancel)
|
||||
GET /_paymentgatewaysubscriptions/api/v1/plans (public)
|
||||
GET …/subscription, POST …/subscription/{checkout,portal,cancel} (jwt)
|
||||
Admin SPA ─► cabana ─► SubscriptionPlans bulk "sync" / toolbar "sync all" ─► Service.SyncPlan ─► Gateway.SyncPlanToGateway
|
||||
Nuxt app (vue-paymentgateway-layer) ─► customer API ─► window.location = checkout_url / portal_url
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
sm-paymentgatewaysubscriptions-plugin/ (new repo, branch main, package pgsub-ish root)
|
||||
├── plugin.go # Plugin: ID golem15.paymentgatewaysubscriptions, Requires paymentgateway+user, Boot collects registries
|
||||
├── routes.go # customer API group under /_paymentgatewaysubscriptions/api/v1
|
||||
├── admin.go # controllers, navigation, permissions, field types
|
||||
├── config/config.yaml # grace_period_days, api.success_url/cancel_url/portal_return_url
|
||||
├── lang/{en,pl}/lang.yaml
|
||||
├── contracts/ # leaf: Subscriber, SubscriberResolver, Gateway, CheckoutOptions, CheckoutSession, Update, Invoice; HasSubscribables, HasSubscriptionGateways; registries
|
||||
├── models/ # leaf: Subscription, SubscriptionPlan, ProcessedWebhookEvent, status/gateway consts, checks
|
||||
├── events/ # six typed events + name consts
|
||||
├── classes/ # Service (Apply, Checkout, Cancel, Portal, Current, Active, ActivePlans, SyncPlan, ClaimEvent), UserSubscribable
|
||||
├── controllers/ # subscriptions/, subscriptionplans/ YAML + Go controllers (bulk/toolbar actions)
|
||||
├── api/ # customer API handlers
|
||||
├── updates/ # baseline + users.stripe_customer_id
|
||||
├── assets/js/ # features field element (Pattern 9)
|
||||
└── README.md
|
||||
|
||||
sm-pgstripe-plugin/
|
||||
├── (root, P18) # + migration: subscription_webhook_secret, test_subscription_webhook_secret
|
||||
│ # + Settings fields, SecretFallbacks, config keys, import-winter mapping (additive)
|
||||
├── internal/stripefake/ # importable fake for subscription endpoints (or keep inside subscriptions/ tests)
|
||||
└── subscriptions/ # package subscriptions, plugin golem15.pgstripesubscriptions
|
||||
├── plugin.go # Requires golem15.pgstripe + golem15.paymentgatewaysubscriptions; HasRoutes, HasJobs, HasSubscriptionGateways, BucketProvider, HasConfig
|
||||
├── gateway.go # Stripe implementation of pgsub Gateway (from d00f850)
|
||||
├── webhook.go # handler + minimal decode
|
||||
├── job.go # River job: claim, map, Apply
|
||||
├── config/config.yaml # metadata_app
|
||||
└── host_test.go # SC-4 proof host
|
||||
|
||||
vue-paymentgateway-layer/
|
||||
├── app/utils/subscriptionsClient.ts, app/composables/useSubscriptionsApi.ts
|
||||
├── app/components/Pg/{Plans,MySubscription,SubscribeButton,ManageButton}.vue
|
||||
└── i18n/locales/{en,pl}.json (+ pg.subscriptions.*)
|
||||
```
|
||||
|
||||
### Pattern 1: Gateway-neutral core, Stripe in the subpackage
|
||||
|
||||
**What:** pgsub defines the contract and owns every rule that is not about Stripe: the upsert of a subscription from a neutral `Update`, the status/cancel-flag diff, the six events, grace and feature checks. The Stripe job only maps JSON to `Update` and calls `Service.Apply`.
|
||||
**When:** always; it is what lets a future Google Play gateway (D-05) reuse the event logic.
|
||||
|
||||
```go
|
||||
// contracts (pgsub, leaf) — names are discretionary
|
||||
type Gateway interface {
|
||||
Identifier() string // "stripe" (models.GatewayStripe)
|
||||
GetOrCreateCustomer(ctx context.Context, tx *gorm.DB, s Subscriber) (string, error)
|
||||
CreateCheckoutSession(ctx context.Context, tx *gorm.DB, s Subscriber, plan *models.SubscriptionPlan, successURL, cancelURL string, o CheckoutOptions) (CheckoutSession, error)
|
||||
CreateManagementSession(ctx context.Context, s Subscriber, returnURL string) (string, error)
|
||||
CancelSubscription(ctx context.Context, sub *models.Subscription, atPeriodEnd bool) error
|
||||
SyncSubscription(ctx context.Context, tx *gorm.DB, sub *models.Subscription) error
|
||||
SyncPlanToGateway(ctx context.Context, tx *gorm.DB, plan *models.SubscriptionPlan) (map[string]string, error)
|
||||
SupportsCurrency(currency string) bool
|
||||
}
|
||||
type CheckoutOptions struct {
|
||||
Currency string // PHP $options['currency'], default "usd"
|
||||
Metadata map[string]string // replaces the QuestStream-only billing_owner_id
|
||||
}
|
||||
type CheckoutSession struct{ URL, SessionID string } // PHP array{url, session_id}
|
||||
```
|
||||
|
||||
PHP's `getWebhookSecret()`/`verifyWebhookSignature()` are Stripe-only and are used only by the Stripe webhook; keep them off the Go interface (discretionary deviation, record it in the README). `[VERIFIED: contracts/SubscriptionGateway.php read this session]`
|
||||
|
||||
### Pattern 2: Build-time registries collected in Boot (the P18 operator pattern)
|
||||
|
||||
**What:** pgsub's Boot walks `party.Activated` and collects `HasSubscribables` (morph alias → resolver) and `HasSubscriptionGateways` (identifier → gateway), failing boot on duplicates, then publishes both. This mirrors `bootOperators` in `sm-paymentgateway-plugin/boot.go` (it collects in the gateway's Boot; dependants' `Register` has already run, so their `p.app` is set).
|
||||
**Rule (D-08):** resolving a `subscriber_type` the registry does not know returns `ErrUnknownSubscriberType` naming the alias; never skip.
|
||||
|
||||
```go
|
||||
// Source: pattern of sm-paymentgateway-plugin/boot.go bootOperators (read this session)
|
||||
activated, _ := app.Lookup[party.Activated]()
|
||||
for _, plugin := range activated {
|
||||
if hs, ok := plugin.(contracts.HasSubscribables); ok {
|
||||
for _, r := range hs.Subscribables() {
|
||||
if err := subs.Add(plugin.ID(), r); err != nil { return err } // duplicate alias names both plugins
|
||||
}
|
||||
}
|
||||
if hg, ok := plugin.(contracts.HasSubscriptionGateways); ok { /* same for gateways */ }
|
||||
}
|
||||
return app.Publish(subs) // and app.Publish(gateways)
|
||||
```
|
||||
|
||||
### Pattern 3: Webhook handler (both URLs, one handler)
|
||||
|
||||
Order of checks keeps PHP's responses: missing secret → 500 `Webhook secret not configured`; bad signature or unreadable payload → 400 (`Invalid signature` / `Invalid payload`); marker exists → 200 `Already processed`; otherwise enqueue the five types and answer 200 `Webhook received` `[VERIFIED: StripeWebhookController.php:29-88 read this session]`. Note PHP checks the secret **before** the signature header; P18's `VerifySignature` returns `ErrMissingSignature` first when the header is empty — check the secret explicitly first to keep the 500. Read the body with a size cap (Stripe events are small; 1 MiB is plenty) and verify the **raw** bytes.
|
||||
|
||||
The five types, verbatim: `'customer.subscription.created'`, `'customer.subscription.updated'`, `'customer.subscription.deleted'`, `'invoice.paid'`, `'invoice.payment_failed'` (`StripeWebhookController.php:67-73`).
|
||||
|
||||
The two URLs, verbatim: `Route::post('/webhook/stripe/subscriptions', …)->middleware('throttle:60,1')` (pgstripe `http/routes.php:15-17`) and `Route::group(['prefix' => 'api/subscriptions'] … Route::post('webhook', …)->middleware('throttle:60,1')` (pgsub `http/routes.php:13-24`). Register both from the subpackage with one `surf.Bucket` (60 per minute per client IP, the PHP `throttle:60,1`; Laravel keys guest throttles by IP, shared across routes with the same limiter — `[ASSUMED]`). Plugin routes carry no CSRF (P18 `routes.go` comment).
|
||||
|
||||
### Pattern 4: Job with claim-first marker (STRIPE-006 + STRIPE-007 + concurrency)
|
||||
|
||||
```go
|
||||
// Inside the River job (conga.Job). One transaction; return error → rollback → River retry.
|
||||
err := gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Exec(`INSERT INTO golem15_pgsub_processed_webhook_events
|
||||
(event_id, event_type, processed_at, created_at, updated_at)
|
||||
VALUES (?, ?, NOW(), NOW(), NOW()) ON CONFLICT (event_id) DO NOTHING`, args.EventID, args.EventType)
|
||||
if res.Error != nil { return res.Error }
|
||||
if res.RowsAffected == 0 { return nil } // already processed (worker-tier check, STRIPE-006 test 2)
|
||||
if skip := appTagMismatch(args, cfg); skip { return nil } // marker kept (STRIPE-007)
|
||||
return route(ctx, tx, args) // Apply + events; any error rolls the marker back (STRIPE-006)
|
||||
})
|
||||
```
|
||||
|
||||
The marker is written first but only **committed** with the change, so the observable rule "marker only after success" holds, and a second concurrent job for the same event blocks on the uncommitted unique key instead of running listeners twice. PHP wrote no `processed_at` from the job (its `create` omits it, and `'gateway' => 'stripe'` is silently dropped because `gateway` is neither a column nor fillable) — setting `processed_at` is a harmless improvement. `[VERIFIED: ProcessStripeSubscriptionWebhook.php:96-105, ProcessedWebhookEvent.php fillable read this session]`
|
||||
|
||||
### Pattern 5: Typed events fired inside the transaction (P18 convention)
|
||||
|
||||
```go
|
||||
// events (pgsub) — names verbatim from ProcessStripeSubscriptionWebhook.php:291,321,351,371,385,394,403
|
||||
const SubscriptionActivatedEventName = "golem15.pgsub.subscription.activated"
|
||||
const SubscriptionCanceledEventName = "golem15.pgsub.subscription.canceled"
|
||||
const SubscriptionPastDueEventName = "golem15.pgsub.subscription.past_due"
|
||||
const SubscriptionReactivatedEventName = "golem15.pgsub.subscription.reactivated"
|
||||
const InvoicePaidEventName = "golem15.pgsub.invoice.paid"
|
||||
const InvoicePaymentFailedEventName = "golem15.pgsub.invoice.payment_failed"
|
||||
|
||||
type SubscriptionEvent struct {
|
||||
Subscription *models.Subscription
|
||||
Tx *gorm.DB // listeners write here; mail/HTTP/cache side effects go in lagoon.AfterCommit
|
||||
}
|
||||
type SubscriptionActivated struct{ SubscriptionEvent }
|
||||
// … Canceled, PastDue, Reactivated
|
||||
type InvoiceEvent struct {
|
||||
Subscription *models.Subscription
|
||||
Invoice contracts.Invoice // ID, CustomerID, Currency, AmountPaid int64 (minor units), AttemptCount, BillingReason …
|
||||
Tx *gorm.DB
|
||||
}
|
||||
type InvoicePaid struct{ InvoiceEvent }
|
||||
type InvoicePaymentFailed struct{ InvoiceEvent }
|
||||
```
|
||||
|
||||
PHP passed the raw invoice array as the second event argument; Go carries a typed, PII-free subset (Security § T-6). A listener error returned from `festival.Bus.Fire` fails the job and retries the whole event — document that listeners must be idempotent.
|
||||
|
||||
### Pattern 6: Stripe object decoding that tolerates both API shapes
|
||||
|
||||
```go
|
||||
type subscriptionObject struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Customer pgstripe.ExpandableID `json:"customer"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||
CanceledAt int64 `json:"canceled_at"`
|
||||
EndedAt int64 `json:"ended_at"`
|
||||
TrialEnd int64 `json:"trial_end"`
|
||||
CurrentPeriodStart int64 `json:"current_period_start"` // pre-basil endpoints only
|
||||
CurrentPeriodEnd int64 `json:"current_period_end"` // pre-basil endpoints only
|
||||
Items struct {
|
||||
Data []struct {
|
||||
Price struct{ ID string `json:"id"` } `json:"price"`
|
||||
CurrentPeriodStart int64 `json:"current_period_start"` // basil and later
|
||||
CurrentPeriodEnd int64 `json:"current_period_end"`
|
||||
} `json:"data"`
|
||||
} `json:"items"`
|
||||
}
|
||||
type invoiceObject struct {
|
||||
ID string `json:"id"`
|
||||
Subscription pgstripe.ExpandableID `json:"subscription"` // pre-basil
|
||||
Parent *struct {
|
||||
SubscriptionDetails *struct {
|
||||
Subscription pgstripe.ExpandableID `json:"subscription"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
} `json:"subscription_details"`
|
||||
} `json:"parent"` // basil and later
|
||||
Customer pgstripe.ExpandableID `json:"customer"`
|
||||
Currency string `json:"currency"`
|
||||
AmountPaid int64 `json:"amount_paid"`
|
||||
AttemptCount int64 `json:"attempt_count"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
```
|
||||
|
||||
Subscription id of an invoice = `parent.subscription_details.subscription`, else top-level `subscription` — the same order Horoskopia's own controller uses (`HoroscopeStripeWebhookController.php:260-265`, read this session). `pgstripe.ExpandableID` already accepts string, null or expanded object.
|
||||
|
||||
### Pattern 7: Built-in `user` subscribable (opt-in)
|
||||
|
||||
- An app opts in by returning `pgsub.UserSubscribable()` from its own plugin's `Subscribables()` (explicit Go, no config flag). The proof host's fixture plugin does the same.
|
||||
- Alias `user`; key `users.id`; email `users.email`; name `trim(name + " " + surname)`, falling back to the email. **Deviation:** Horoskopia's adapter falls back to `username`, but the Go `users` table has no `username` column (`sm-user-plugin/models/user.go` read this session). Record it.
|
||||
- Customer id read/written with a targeted query on `users.stripe_customer_id` (`tx.Table("users").Where("id = ?", id).Update("stripe_customer_id", v)`), never a full `Save` of `models.User` (which lacks the field). Soft-deleted users still resolve (the subscription row outlives the account) — use `Unscoped()` for the read.
|
||||
|
||||
### Pattern 8: Plan sync as admin actions without importing Stripe
|
||||
|
||||
`SubscriptionPlans` declares `bulkActions: [sync_to_stripe]` (needs `showCheckboxes: true`) and a toolbar action `sync_all_to_stripe`. Both call `Service.SyncPlan` → `registry.Gateway("stripe").SyncPlanToGateway`. A missing gateway is a `cabana.ForbiddenError` with a clear message ("no subscription gateway is installed"). **Bulk actions run inside a transaction with the rows locked** (`pact.AdminBulkActionInput` docs, read this session): never return an error for a single plan's Stripe failure — collect `code: message` per plan and return them in `Message`, exactly like PHP's flash (`controllers/SubscriptionPlans.php:60-143`), otherwise a rollback forgets the `stripe_product_id` Stripe already created and the next sync creates a duplicate product. Free plans (`price_cents = 0`) are skipped and counted.
|
||||
|
||||
### Pattern 9: `features` as a pgsub plugin field type
|
||||
|
||||
cabana has no `taglist`. A pgsub `pact.FormFieldType` (precedent: P18's `quote-cart.js` element) with `Decode` accepting a JSON array of non-empty strings (trim, dedupe, cap length) and `Load` returning the stored list keeps the stored v2.4.0 shape (a flat JSON array of strings). A `repeater` would store a list of objects — wrong shape. A generic core `taglist` is the cleaner long-term home; capture it as a framework todo.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Stripe types in pgsub:** pgsub must compile without stripe-go (D-06). Only the subpackage imports `stripe-go`.
|
||||
- **Marker written in the handler** (the original STRIPE-006 bug) or after commit in a second transaction (lost update window).
|
||||
- **`LIKE '%price_id%'` plan lookup** (PHP job line 192): `price_1` matches `price_10`. Match `stripe_price_id = ?` first, then decode `stripe_prices` maps in Go and compare values exactly.
|
||||
- **Redirect URLs from the request** in the customer API (open redirect through Stripe `success_url`/`return_url`).
|
||||
- **Firing events after commit with stale state**, or firing outside the tx in some paths and inside in others. Follow P18: inside, with `Tx`.
|
||||
- **Holding a DB transaction across many Stripe calls in a request path** (customer API checkout): call Stripe outside a long tx; persist `stripe_customer_id` in its own short write.
|
||||
|
||||
## v2.4.0 Schema Baseline
|
||||
|
||||
Derived from `updates/v1.0.1 … v2.4.0` and **diffed against a real PHP database**: `horoskopia.eu/storage/db-backups/v2.1-pre-migrate-20260501T211242Z.sql` (MariaDB dump of database `g15start`, 2026-05-01; lines 5198106–5198215 read this session). The dump post-dates v2.4.0 (it has `currency_prices`). No QuestStream dump was found. `[VERIFIED: dump read this session]`
|
||||
|
||||
Real MariaDB DDL (verbatim, abbreviated to the columns and keys):
|
||||
|
||||
```
|
||||
CREATE TABLE `golem15_pgsub_processed_webhook_events` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`event_id` varchar(255) NOT NULL,
|
||||
`event_type` varchar(255) DEFAULT NULL,
|
||||
`processed_at` timestamp NULL DEFAULT NULL,
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
UNIQUE KEY `golem15_pgsub_processed_webhook_events_event_id_unique` (`event_id`),
|
||||
KEY `golem15_pgsub_processed_webhook_events_event_id_index` (`event_id`)
|
||||
CREATE TABLE `golem15_pgsub_subscription_plans` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`code` varchar(50) NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`description` text DEFAULT NULL,
|
||||
`stripe_price_id` varchar(255) DEFAULT NULL,
|
||||
`stripe_product_id` varchar(255) DEFAULT NULL,
|
||||
`stripe_prices` longtext … CHECK (json_valid(`stripe_prices`)),
|
||||
`price_cents` int(10) unsigned NOT NULL DEFAULT 0,
|
||||
`currency_prices` longtext … CHECK (json_valid(`currency_prices`)),
|
||||
`currency` varchar(3) NOT NULL DEFAULT 'EUR',
|
||||
`billing_interval` varchar(20) NOT NULL DEFAULT 'month',
|
||||
`features` longtext … CHECK (json_valid(`features`)),
|
||||
`max_quotes_per_month` int(10) unsigned DEFAULT NULL,
|
||||
`lead_fee_required` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`is_active` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`sort_order` int(11) NOT NULL DEFAULT 0,
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
UNIQUE KEY `golem15_pgsub_subscription_plans_code_unique` (`code`),
|
||||
KEY `golem15_pgsub_subscription_plans_code_index` (`code`),
|
||||
KEY `golem15_pgsub_subscription_plans_is_active_index` (`is_active`),
|
||||
KEY `golem15_pgsub_subscription_plans_sort_order_index` (`sort_order`)
|
||||
CREATE TABLE `golem15_pgsub_subscriptions` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`subscriber_type` varchar(255) DEFAULT NULL,
|
||||
`subscriber_id` int(10) unsigned DEFAULT NULL,
|
||||
`gateway` varchar(20) NOT NULL DEFAULT 'stripe',
|
||||
`subscription_plan_id` int(10) unsigned NOT NULL,
|
||||
`stripe_subscription_id` varchar(255) DEFAULT NULL,
|
||||
`stripe_customer_id` varchar(255) DEFAULT NULL,
|
||||
`stripe_status` enum('incomplete','incomplete_expired','trialing','active','past_due','canceled','unpaid','paused') NOT NULL DEFAULT 'incomplete',
|
||||
`current_period_start` timestamp NULL DEFAULT NULL,
|
||||
`current_period_end` timestamp NULL DEFAULT NULL,
|
||||
`cancel_at_period_end` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`canceled_at` timestamp NULL DEFAULT NULL,
|
||||
`ended_at` timestamp NULL DEFAULT NULL,
|
||||
`trial_ends_at` timestamp NULL DEFAULT NULL,
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
UNIQUE KEY `golem15_pgsub_subscriptions_stripe_subscription_id_unique` (`stripe_subscription_id`),
|
||||
KEY `golem15_pgsub_subscriptions_subscription_plan_id_index` (`subscription_plan_id`),
|
||||
KEY `golem15_pgsub_subscriptions_stripe_status_index` (`stripe_status`),
|
||||
KEY `golem15_pgsub_subscriptions_current_period_end_index` (`current_period_end`),
|
||||
KEY `pgsub_subscriber_index` (`subscriber_type`,`subscriber_id`),
|
||||
KEY `pgsub_gateway_index` (`gateway`),
|
||||
CONSTRAINT `golem15_pgsub_subscriptions_subscription_plan_id_foreign` FOREIGN KEY (`subscription_plan_id`) REFERENCES `golem15_pgsub_subscription_plans` (`id`)
|
||||
```
|
||||
|
||||
**Diff, migrations vs real DB:** identical names, order and nullability. The v2.0.0 "Step 4: make polymorphic columns NOT NULL" never executes (it only lists indexes), so `subscriber_type`/`subscriber_id` stay **nullable** in the real DB — the baseline must keep them nullable; "required" is a model rule. The FK has no `ON DELETE` clause in the dump because RESTRICT is MySQL's default (`onDelete('restrict')` in v1.0.1). `golem15_pgsub_usage_records` and `golem15_pgsub_featured_placements` are **absent** (S-3).
|
||||
|
||||
**Real data worth knowing** (dump lines 5198173–5198177, 5198219 area): plans `starter`/`professional`/`premium` (the v1.0.1 seed **did** run on this DB) plus `premium-monthly` (`stripe_product_id` `prod_…`, `stripe_prices` `{"pln":"price_…"}`, `price_cents` 2999, `currency_prices` `{"pln":2999}`, `currency` **`pln` lowercase**) and `premium-yearly` (24999, `year`, no Stripe ids); `features` is `'[]'` on every row; one subscription `(1,'user',1,'stripe',4,NULL,NULL,'active',…)` — a manual grant with no Stripe ids. Consequences: currency must be treated case-insensitively (`strings.ToLower` like PHP's `getPriceCents`), plans can coexist with duplicate `sort_order`, and `stripe_subscription_id` NULL rows are legal (Postgres UNIQUE allows many NULLs, like MySQL).
|
||||
|
||||
**Recommended Postgres baseline (one migration, idempotent):**
|
||||
|
||||
```sql
|
||||
-- ID suggestion: 202610150001_create_golem15_pgsub_baseline (folds v1.0.1, v1.0.2 create, v2.0.0, v2.1.0, v2.2.0, v2.3.0, v2.4.0)
|
||||
CREATE TABLE IF NOT EXISTS golem15_pgsub_subscription_plans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
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 TEXT NULL, -- JSON object currency → price id (lagoon.Jsonable)
|
||||
price_cents INTEGER NOT NULL DEFAULT 0,
|
||||
currency_prices TEXT NULL, -- JSON object currency → cents
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
|
||||
billing_interval VARCHAR(20) NOT NULL DEFAULT 'month',
|
||||
features TEXT NULL, -- JSON array of strings
|
||||
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 TIMESTAMPTZ NULL,
|
||||
updated_at TIMESTAMPTZ NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS golem15_pgsub_subscription_plans_code_unique ON golem15_pgsub_subscription_plans (code);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_subscription_plans_code_index ON golem15_pgsub_subscription_plans (code);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_subscription_plans_is_active_index ON golem15_pgsub_subscription_plans (is_active);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_subscription_plans_sort_order_index ON golem15_pgsub_subscription_plans (sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS golem15_pgsub_subscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
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 TIMESTAMPTZ NULL,
|
||||
current_period_end TIMESTAMPTZ NULL,
|
||||
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
canceled_at TIMESTAMPTZ NULL,
|
||||
ended_at TIMESTAMPTZ NULL,
|
||||
trial_ends_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NULL,
|
||||
updated_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT golem15_pgsub_subscriptions_stripe_status_check CHECK (stripe_status IN
|
||||
('incomplete','incomplete_expired','trialing','active','past_due','canceled','unpaid','paused')),
|
||||
CONSTRAINT golem15_pgsub_subscriptions_subscription_plan_id_foreign FOREIGN KEY (subscription_plan_id)
|
||||
REFERENCES golem15_pgsub_subscription_plans (id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS golem15_pgsub_subscriptions_stripe_subscription_id_unique ON golem15_pgsub_subscriptions (stripe_subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_subscriptions_subscription_plan_id_index ON golem15_pgsub_subscriptions (subscription_plan_id);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_subscriptions_stripe_status_index ON golem15_pgsub_subscriptions (stripe_status);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_subscriptions_current_period_end_index ON golem15_pgsub_subscriptions (current_period_end);
|
||||
CREATE INDEX IF NOT EXISTS pgsub_subscriber_index ON golem15_pgsub_subscriptions (subscriber_type, subscriber_id);
|
||||
CREATE INDEX IF NOT EXISTS pgsub_gateway_index ON golem15_pgsub_subscriptions (gateway);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS golem15_pgsub_processed_webhook_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id VARCHAR(255) NOT NULL,
|
||||
event_type VARCHAR(255) NULL,
|
||||
processed_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NULL,
|
||||
updated_at TIMESTAMPTZ NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS golem15_pgsub_processed_webhook_events_event_id_unique ON golem15_pgsub_processed_webhook_events (event_id);
|
||||
CREATE INDEX IF NOT EXISTS golem15_pgsub_processed_webhook_events_event_id_index ON golem15_pgsub_processed_webhook_events (event_id);
|
||||
```
|
||||
|
||||
Second migration (D-09), its own ID so it can be reasoned about separately:
|
||||
|
||||
```sql
|
||||
-- 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 ON users (stripe_customer_id);
|
||||
-- Rollback: no-op (the column may predate this plugin: Horoskopia's horoscope plugin adds the same column, add_stripe_customer_id_to_users.php:13-18)
|
||||
```
|
||||
|
||||
Type choices: TEXT for the three JSON columns matches `lagoon.Jsonable` ("stores JSON as TEXT") and P18's `data TEXT`; `TIMESTAMPTZ` and `SERIAL`/`INTEGER` follow every P18 baseline. Constraint names in Postgres are schema-unique per table namespace; `CREATE TABLE IF NOT EXISTS` skips the inline `CONSTRAINT` clauses when the table exists, so the check and FK are only guaranteed on a fresh DB — acceptable because Phase 20 imports into a Go-created schema. The pgsub migrations must run after `golem15.user` (D-06 `Requires` guarantees activation order; `lagoon.Migrate` runs plugin sets in `party.Activate` order, read this session).
|
||||
|
||||
If the user keeps the dormant tables (S-3), take their DDL from `v1.0.3/create_usage_records_table.php` and `v1.0.4/create_featured_placements_table.php` minus the FKs to `golem15_quotify_*` (enums become `VARCHAR … CHECK`).
|
||||
|
||||
## Event and State Mapping
|
||||
|
||||
Status values verbatim from `models/Subscription.php:28-35`: `'incomplete'`, `'incomplete_expired'`, `'trialing'`, `'active'`, `'past_due'`, `'canceled'`, `'unpaid'`, `'paused'`. Gateway values verbatim `:40-41`: `'stripe'`, `'google_play'`. Validation rules verbatim `:83-89`: `'gateway' => 'nullable|in:stripe,google_play'`, `'stripe_status' => 'required|in:incomplete,incomplete_expired,trialing,active,past_due,canceled,unpaid,paused'`, `subscriber_type` `required|string`, `subscriber_id` `required|integer`, `subscription_plan_id` `required|exists:…`. `[VERIFIED: Read this session]`
|
||||
|
||||
| Stripe event | Row change (job) | Events fired (`fireStateChangeEvents`, job lines 362–408) |
|
||||
|--------------|------------------|-----------------------------------------------------------|
|
||||
| `customer.subscription.created` / `.updated` | Upsert by `stripe_subscription_id` (`firstOrNew`): `subscriber_type`/`subscriber_id` from `metadata` (only when both present); plan from item[0] price id (`stripe_price_id` column, then `stripe_prices` map); `stripe_customer_id` = `customer`; `stripe_status` = `status` (default `incomplete`); `gateway` = `stripe`; periods from item[0] (Go: fall back to top level); `cancel_at_period_end`; `canceled_at`, `ended_at`, `trial_ends_at` only when non-zero (never cleared) | 1. **reactivated** if previous `cancel_at_period_end` true → now false and status ∈ {active, trialing} (checked **before** the status diff, so it can fire with no status change). 2. If status unchanged → stop. 3. **activated** if now ∈ {active, trialing} and previous ∉ {active, trialing} (previous null on create counts). 4. **past_due** if now `past_due` and previous ≠. 5. **canceled** if now `canceled` and previous ≠ |
|
||||
| `customer.subscription.deleted` | Find by `stripe_subscription_id`; not found → warn, return (marker written). Set `stripe_status = canceled`, `ended_at = now()` | **canceled** unconditionally (even if already canceled) |
|
||||
| `invoice.paid` | No row change. Subscription id: Go reads `parent.subscription_details.subscription`, else `subscription`; missing → return; unknown → debug, return | **invoice.paid** (subscription, invoice) |
|
||||
| `invoice.payment_failed` | Same lookup; no row change (the `past_due` status arrives through `customer.subscription.updated`) | **invoice.payment_failed** (subscription, invoice) |
|
||||
|
||||
Behaviours to keep or decide explicitly:
|
||||
|
||||
- **Unknown price / missing metadata on create:** PHP's `save()` fails Winter validation (`subscription_plan_id` required, `subscriber_*` required) → exception → retry until the queue gives up. Go equivalent: `Apply` returns a validation error → River retries (conga default attempts; consider `conga.MaxAttempts`). Keep the retry (an admin may sync plans later) but log at Error with ids.
|
||||
- **Unknown `subscriber_type` in metadata:** D-08 says fail loudly → error, retry, Error log.
|
||||
- **Grace period:** `isInGracePeriod` = status `past_due` and `now < current_period_end + grace_period_days` (config default 7; PHP env `SUBSCRIPTION_GRACE_PERIOD_DAYS`); `hasAccess` = active/trialing or in grace; null period end → no grace. `[VERIFIED: Subscription.php:191-225]`
|
||||
- **`days_until_renewal`:** Carbon 2.73 (`composer.lock`) `diffInDays(…, false)` → whole days, `max(0, …)`; null when no period end.
|
||||
- **`isStripeSubscription`:** true for `stripe` **or NULL** gateway (backward compatibility).
|
||||
- **`hasFeature`:** PHP accepts list values and truthy map keys; v2.4.0 data is a flat list (D-03). Go: list membership; optionally accept the legacy map shape when decoding (cheap, avoids a 500 on an old row).
|
||||
- **No ordering guarantee:** an `invoice.paid` processed before `customer.subscription.created` finds no row and is marked processed — the event never fires (same as PHP). Document it; Horoskopia does not listen to `invoice.*` through pgsub (it keeps its own `/webhook/stripe/horoscope` endpoint for invoice facts).
|
||||
|
||||
## Horoskopia Consumer → Go Service API
|
||||
|
||||
The acceptance consumer (`horoscope/routes.php` 362–760, `Plugin.php` 371–505, `SubscribableUserAdapter.php`, `console/ReconcileSubscriptions.php`, read this session) needs:
|
||||
|
||||
| PHP call | Go service API (names discretionary) |
|
||||
|----------|--------------------------------------|
|
||||
| `SubscriptionPlan::where('is_active', true)->orderBy('sort_order')->get()` (`plans`) | `svc.ActivePlans(ctx) ([]models.SubscriptionPlan, error)` |
|
||||
| `Subscription::forSubscriber('user', $id)->with('plan')->latest()->first()` + `hasAccess()` (`my/subscription`) | `svc.Latest(ctx, alias, id)` (created_at desc, id desc) with `Plan` preloaded; `svc.HasAccess(sub)` (grace from config) |
|
||||
| `->active()->first()` (409 `already_subscribed`, cancel lookup) | `svc.Active(ctx, alias, id)` |
|
||||
| `SubscriptionPlan::where('code', $code)->active()->first()` | `svc.ActivePlanByCode(ctx, code)` |
|
||||
| `app(SubscriptionGateway::class)->createCheckoutSession(adapter, plan, success, cancel, ['currency' => 'pln'])` → `url`, `session_id` | `svc.Checkout(ctx, subscriber, plan, successURL, cancelURL, CheckoutOptions{Currency: "pln"}) (CheckoutSession, error)`; `subscriber` from `svc.Subscriber(ctx, "user", id)` |
|
||||
| `$user->stripe_customer_id` (portal 404 `no_stripe_customer`, invoice join) | `subscriber.GatewayCustomerID()`; the column stays readable by the app directly |
|
||||
| `cancelSubscription($subscription, true)` | `svc.Cancel(ctx, sub, true)` |
|
||||
| `createManagementSession($user, returnUrl)` | `svc.Portal(ctx, subscriber, returnURL) (string, error)` |
|
||||
| `$subscription->current_period_end`, `cancel_at_period_end`, `days_until_renewal`, `stripe_status`, `plan->code/name/price_cents/currency` | model fields + `DaysUntilRenewal(now)` |
|
||||
| `Event::listen('golem15.pgsub.subscription.{activated,canceled,past_due,reactivated}', fn($subscription))` with `subscriber_type === 'user'` checks | `festival.Listen` on the four typed events; `e.Subscription.SubscriberType`/`SubscriberID` |
|
||||
| `getIsPremiumAttribute` = `forSubscriber('user', id)->active()->exists()` | `svc.Active(...) != nil` (note: **active**, not `hasAccess` — the two Horoskopia checks differ; keep both available) |
|
||||
| `ReconcileSubscriptions`: `whereNotIn('stripe_status', [canceled, incomplete_expired])` then `$gateway->syncSubscription($sub)`; manual-grant expiry sets `stripe_status = canceled` with a plain save (no events) | `gateway.SyncSubscription(ctx, tx, sub)` exposed through `svc.SyncSubscription`; model status constants exported |
|
||||
|
||||
The Go sequence for checkout stays three calls (resolve subscriber, find plan, `Checkout`) — as short as PHP's.
|
||||
|
||||
## Customer API (D-13)
|
||||
|
||||
Prefix `/_paymentgatewaysubscriptions/api/v1` (pattern `/_x/api/v1`; the route group, throttle bucket and JWT helpers follow `sm-paymentgateway-plugin/routes.go` and `api/auth.go`, read this session). Envelope follows P18: success JSON, errors `{"error": message}` (401/404/409/500) and the 422 `{"error": "Validation failed", "errors": {...}}`.
|
||||
|
||||
| Method + path | Auth | Behaviour (succeeds `SubscriptionManager`) |
|
||||
|---------------|------|--------------------------------------------|
|
||||
| GET `/plans` | public | Active plans by `sort_order`: code, name, description, billing_interval, prices as P18 Money strings per currency (from `currency_prices` falling back to `price_cents`/`currency`), features |
|
||||
| GET `/subscription` | jwt | The `user` subscriber's current subscription (active-or-latest; planner picks, document it), plan summary, status, periods, `cancel_at_period_end`, `has_access`, `in_grace_period` |
|
||||
| POST `/subscription/checkout` `{plan_code, currency?}` | jwt | 409 when an active subscription exists (the component redirected to the portal; an API should not); 404 unknown/inactive plan; 422 free plan (`price_cents = 0`) or unsupported currency; else `{checkout_url, session_id}` |
|
||||
| POST `/subscription/portal` | jwt | 404 when the user has no customer id; else `{portal_url}` |
|
||||
| POST `/subscription/cancel` | jwt | At period end only (immediate cancel stays a service/admin capability); 404 when no active subscription; answers the period end |
|
||||
|
||||
Redirect targets come from config (`api.success_url`, `api.cancel_url`, `api.portal_return_url`, absolute or relative to `app.url`), never from the request body (Security T-4). Checkout writes `stripe_customer_id` in a short transaction of its own before calling Stripe for the session.
|
||||
|
||||
## Admin Screens
|
||||
|
||||
Permission codes verbatim (`Plugin.php:137-147`): `golem15.paymentgatewaysubscriptions.manage_subscriptions`, `golem15.paymentgatewaysubscriptions.manage_plans`, `golem15.paymentgatewaysubscriptions.manage_placements`. Recommendation for the discretion item: **keep `manage_placements` registered** (a permission code is cheap, it keeps role exports from PHP valid at import) but **no navigation entry** for featured placements. Navigation: top-level `subscriptions` (order 510) with side items `subscriptions` and `plans`.
|
||||
|
||||
- **Subscriptions:** list + filter + **preview-only** record (copy P18's `controllers/payments` config: `recordUrl …/preview/:id`, `preview:` block, no `create` button). Columns adapted: `subscriber_type`, `subscriber_id` (searchable), plan via `relation: plan, select: name`, `stripe_status` via a pgsub list field type showing the localized status label (P18 `orderstate` precedent), `current_period_end` datetime, `cancel_at_period_end`, `created_at`. The subscriber display name goes on the preview through `type: partial` + `pact.AdminPartialData` (curated view model resolved through the registry). Filter scopes `stripe_status` (group) and `plan` (model-backed). PHP's form had every field `readOnly` and a create button whose form could not be filled — dropping create is not a functional loss.
|
||||
- **SubscriptionPlans:** list (checkboxes on), create/update form with `code`, `name`, `description`, `stripe_price_id`, `price_cents` (number, cents — PHP parity), `currency`, `billing_interval` (`month`/`year`), `features` (Pattern 9), `max_quotes_per_month`, `lead_fee_required`, `is_active`, `sort_order`; rules from `SubscriptionPlan.php` (`code` required+unique, `name` required max 255, `price_cents` required integer min 0, `currency` required size 3, `billing_interval` in month,year). Bulk `sync_to_stripe` + toolbar `sync_all_to_stripe` (Pattern 8). No reorder page (S-6).
|
||||
- EN and PL phrase files (P15 D-06).
|
||||
|
||||
## Layer additions (D-14)
|
||||
|
||||
`vue-paymentgateway-layer` at `ac2b960`, tag `v0.1.0` (read this session). Its runtime config has one base (`paymentgateway.apiBase`, public `paymentgateway.apiBase`) for `/_paymentgateway/api/v1`; add `paymentgatewaysubscriptions.apiBase` (server) and its public twin for `/_paymentgatewaysubscriptions/api/v1`, reuse the existing `paymentGatewayAuth` hook and SSR `x-forwarded-for` forwarding. Add a typed client in `app/utils/`, a composable, components (`PgPlans`, `PgMySubscription`, subscribe and manage buttons that redirect with `window.location.href`), `pg.subscriptions.*` strings in EN/PL, README sections and `node --test` specs. Components fetch nothing until mounted, so payments-only apps are unaffected. Release as `v0.2.0` (additive) after the user's go-ahead; the existing one-commit lag of `v0.1.0` is folded in.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't build | Use instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Stripe HMAC check | own HMAC/timestamp code | `pgstripe.VerifySignature` (wraps `webhook.ValidatePayload`) | Multiple `v1` signatures, 300 s tolerance, constant-time compare; already accepts old API versions |
|
||||
| Stripe HTTP | raw `net/http` | `pgstripe.ClientFactory{App}.New(secret)` + `pgstripe.ModeKeys` | Backend override for tests, retries with idempotency keys, timeouts, key never in a global |
|
||||
| Encrypted secrets, write-only admin fields | own crypto/redaction | `lagoon.Encrypted` + cabana `type: password` + `cabana.SecretFallbacks` | Done in P18; `meta.secrets` stored/env/empty |
|
||||
| Laravel-encrypted import | own CBC decrypt | `lagoon.DecryptLaravelPayload` (as `pgstripe:import-winter` does) | P18 importer already handles plaintext and encrypted |
|
||||
| Job queue, retries | goroutines, ticker | `conga.Job` + `conga.Manager.Enqueue` | River retry/backoff, transactional insert |
|
||||
| Duplicate-event guard | in-memory set | Unique index + `INSERT … ON CONFLICT DO NOTHING` in the job tx | Survives restarts and concurrent workers |
|
||||
| Rate limit | own counter | `surf.BucketProvider` bucket | P18 pattern (`plugin.go:101-120`) |
|
||||
| Exact money at the API boundary | float formatting of cents | `sm-paymentgateway-plugin/money` | PITFALLS #5 |
|
||||
| Polymorphic lookup | GORM polymorphic associations | explicit `subscriber_type` + `subscriber_id` columns + registry | Alias strings are the PHP contract; GORM polymorphism writes table names |
|
||||
|
||||
**Key insight:** every hard part (crypto, signature, retries, money, admin secrets) already exists in P18; the new code is mapping and orchestration, so its risk sits in ordering and idempotency, not in primitives.
|
||||
|
||||
## Runtime State Inventory
|
||||
|
||||
Not a rename phase, but the port creates state that lives outside the repos; record it for the cutover:
|
||||
|
||||
| Category | Items found | Action |
|
||||
|----------|-------------|--------|
|
||||
| Stored data | Horoskopia MariaDB rows in `golem15_pgsub_*` (5 plans, ≥1 subscription incl. a manual grant), `users.stripe_customer_id` | Phase 20 / Horoskopia port import (HasWinterImport mapper) — out of scope here |
|
||||
| Live service config | Stripe dashboard endpoints pointing at `/webhook/stripe/subscriptions` or `/api/subscriptions/webhook` with their API versions; existing Stripe subscriptions carrying `metadata.app = queststream` | D-10 keeps URLs; set the app-tag config at cutover (S-5) |
|
||||
| OS-registered state | None — verified: no cron/queue entries are introduced by this phase (a `queue:work`/serve worker already exists per app) | none |
|
||||
| Secrets/env vars | PHP `stripe.webhookSecret`/`stripe.testWebhookSecret` (subscription endpoint's secret on Horoskopia), `STRIPE_WEBHOOK_SECRET`, `SUBSCRIPTION_GRACE_PERIOD_DAYS` | Map to `subscription_webhook_secret` columns / `SUMMER_GOLEM15__PGSTRIPE__SUBSCRIPTION_WEBHOOK_SECRET` / `SUMMER_GOLEM15__PAYMENTGATEWAYSUBSCRIPTIONS__GRACE_PERIOD_DAYS` (S-4) |
|
||||
| Build artifacts | None in this phase | none |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Freezing pgstripe at `8b47457` loses `syncPlanToGateway`
|
||||
**What goes wrong:** The planner ports the gateway from `8b47457` and finds no plan-sync code. **Avoid:** read `git show d00f850:classes/StripeSubscriptionGateway.php` (S-1). **Warning sign:** no `createOrUpdateStripePrice` in the source being ported.
|
||||
|
||||
### Pitfall 2: basil invoice shape
|
||||
**What goes wrong:** `invoice.paid` handlers read `data.object.subscription`, which basil+ endpoints do not send; events silently never fire. **Avoid:** Pattern 6. **Warning sign:** host test only covers the old shape — add both shapes as subtests.
|
||||
|
||||
### Pitfall 3: pre-basil subscription periods
|
||||
**What goes wrong:** an endpoint pinned to an older API version sends `current_period_*` on the subscription, not on items; PHP leaves periods NULL, so grace never applies. **Avoid:** items first, top level second.
|
||||
|
||||
### Pitfall 4: Out-of-order events regress status
|
||||
**What goes wrong:** Stripe does not guarantee order and River retries reorder further; an older `updated` (e.g. `incomplete`) applied after a newer one (`active`) flips access off and may re-fire `activated` later. **Avoid (parity-preserving):** document; optionally guard with the event's `created` timestamp stored per subscription — that needs a column the schema does not have, so leave it as an open question rather than invent schema.
|
||||
|
||||
### Pitfall 5: Bulk action rollback after a successful Stripe call
|
||||
**What goes wrong:** returning an error from the bulk `Run` rolls back `stripe_product_id`, so the next sync creates a duplicate Stripe product. **Avoid:** Pattern 8 (never error per plan).
|
||||
|
||||
### Pitfall 6: Secret checked after the header
|
||||
**What goes wrong:** `VerifySignature` returns `ErrMissingSignature` before `ErrNoWebhookSecret`, turning PHP's 500 into a 400 when both are missing. **Avoid:** resolve the secret first and answer 500 when empty.
|
||||
|
||||
### Pitfall 7: `syncPlanToGateway` creates prices in currencies the plan never set
|
||||
**What goes wrong:** PHP loops `usd`, `eur`, `pln` and `getPriceCents` falls back to `price_cents`, so a PLN-only plan (Horoskopia `premium-monthly`) also gets USD and EUR prices of 2999, and `stripe_price_id` is set to the USD one. **Decide:** port faithfully (parity) or restrict to `currency_prices` keys plus `currency`. Recommend faithful port + flag (it is the frozen contract and QuestStream relies on usd/eur/pln); list as A3.
|
||||
|
||||
### Pitfall 8: Users soft-deleted or missing
|
||||
**What goes wrong:** resolving a subscriber for a soft-deleted user returns not-found and the admin preview 500s. **Avoid:** `Unscoped()` read; a truly missing row yields an empty display name, not an error. Only an unknown **alias** fails loudly.
|
||||
|
||||
### Pitfall 9: The P18 Stripe fake is a test file
|
||||
**What goes wrong:** the subpackage cannot import `stripefake_test.go`. **Avoid:** S-8.
|
||||
|
||||
### Pitfall 10: `LIKE` plan lookup and case
|
||||
**What goes wrong:** substring matches and `PLN` vs `pln` mismatches. **Avoid:** exact map comparison in Go; lower-case currencies on read and write (PHP lower-cases in `getStripePriceId`/`getPriceCents`).
|
||||
|
||||
### Pitfall 11: The PHP job's app filter
|
||||
**What goes wrong:** copying `'queststream'` names an application in core code; dropping the filter entirely makes a shared Stripe account process another app's events. **Avoid:** S-5 config key.
|
||||
|
||||
### Pitfall 12: Plugin ID format
|
||||
**What goes wrong:** `golem15.pgstripe.subscriptions` or `golem15.pgstripe_subscriptions` fails `summer build`. **Avoid:** `golem15.pgstripesubscriptions` (S-9).
|
||||
|
||||
### Pitfall 13: Docker access in this shell
|
||||
**What goes wrong:** testcontainers denied. **Avoid:** per CLAUDE.md run through `sudo -u golem` if the group is stale; this session's shell can reach Docker (`docker ps` works).
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Webhook handler skeleton (subpackage)
|
||||
|
||||
```go
|
||||
// Source: pattern from sm-pgstripe-plugin/client.go + webhook.go (read this session); responses from StripeWebhookController.php:29-88
|
||||
func (p *Plugin) webhook(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
if err != nil { http.Error(w, "Invalid payload", http.StatusBadRequest); return }
|
||||
settings, err := pgmodels.LoadSettings(ctx, p.db())
|
||||
if err != nil { wire.WriteOpaque500(w); return }
|
||||
secret := p.subscriptionWebhookSecret(settings) // mode-aware, column then config fallback
|
||||
if secret == "" {
|
||||
p.log.Error("golem15.pgstripesubscriptions: webhook secret not configured")
|
||||
http.Error(w, "Webhook secret not configured", http.StatusInternalServerError); return
|
||||
}
|
||||
if err := pgstripe.VerifySignature(body, r.Header.Get("Stripe-Signature"), secret); err != nil {
|
||||
p.log.Warn("golem15.pgstripesubscriptions: invalid signature")
|
||||
http.Error(w, "Invalid signature", http.StatusBadRequest); return
|
||||
}
|
||||
ev, err := decodeEvent(body) // id, type, minimal data.object
|
||||
if err != nil { p.log.Warn("…invalid payload"); http.Error(w, "Invalid payload", http.StatusBadRequest); return }
|
||||
if done, err := pgsub.EventProcessed(ctx, p.db(), ev.ID); err == nil && done {
|
||||
http.Error(w, "Already processed", http.StatusOK); return // body text as PHP
|
||||
}
|
||||
if handled(ev.Type) {
|
||||
if err := p.jobs.Enqueue(ctx, p.db(), ev.jobArgs(), conga.EnqueueOpts{}); err != nil { wire.WriteOpaque500(w); return }
|
||||
}
|
||||
w.WriteHeader(http.StatusOK); _, _ = io.WriteString(w, "Webhook received")
|
||||
}
|
||||
```
|
||||
|
||||
(Use plain-text bodies like PHP `response('…', code)`; `http.Error` sets `text/plain` and appends a newline — write the body directly if byte parity matters.)
|
||||
|
||||
### Stripe plan sync (port of `d00f850`)
|
||||
|
||||
```go
|
||||
// Source: StripeSubscriptionGateway.php @ d00f850 lines 438-552 (read this session); stripe-go v87 V1Products/V1Prices
|
||||
func (g *Gateway) SyncPlanToGateway(ctx context.Context, tx *gorm.DB, plan *models.SubscriptionPlan) (map[string]string, error) {
|
||||
if plan.PriceCents == 0 { return map[string]string{}, nil } // "Skipping free plan sync"
|
||||
sc, err := g.client(ctx)
|
||||
if err != nil { return nil, err }
|
||||
if err := g.upsertProduct(ctx, tx, sc, plan); err != nil { return nil, err } // metadata plan_code, tier_level
|
||||
ids := map[string]string{}
|
||||
for _, cur := range []string{"usd", "eur", "pln"} { // PHP list (Pitfall 7)
|
||||
cents := plan.PriceCentsFor(cur) // currency_prices[cur] else price_cents
|
||||
if cents == 0 { continue }
|
||||
id, err := g.upsertPrice(ctx, sc, plan, cur, cents) // lookup_key "<code>_<cur>", archive on change, transfer_lookup_key
|
||||
if err != nil { return nil, err }
|
||||
ids[cur] = id
|
||||
}
|
||||
plan.StripePrices = ids // lagoon.Jsonable[map[string]string]
|
||||
if usd, ok := ids["usd"]; ok { plan.StripePriceID = &usd }
|
||||
return ids, tx.Save(plan).Error
|
||||
}
|
||||
```
|
||||
|
||||
`tier_level` metadata comes from `getTierLevel()` (code prefix `core`/`plus`/`max` → 1/2/3, else 0), a QuestStream convention living in the generic model; port it as product metadata only, without exporting a "tier" API (D-03 spirit).
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old approach | Current approach | When changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `invoice.subscription` | `invoice.parent.subscription_details.subscription` | Stripe `2025-03-31.basil` | Read both (Pattern 6) `[CITED: docs.stripe.com/changelog/basil/2025-03-31/adds-new-parent-field-to-invoicing-objects]` |
|
||||
| `subscription.current_period_*` | `subscription.items.data[].current_period_*` | basil | Read both |
|
||||
| `cancel_at_period_end` parameter | still accepted, documented as deprecated in favour of `cancel_at` enums for flexible billing | basil `2025-05-28` | Keep the parameter for cancel at period end; watch `cancel_at` (Open Question 3) `[CITED: docs.stripe.com/changelog/basil/2025-05-28/cancel-at-enums]` |
|
||||
| stripe-go global `stripe.Key` | `stripe.NewClient` + `V1*` services | v82+ | Already used by P18 |
|
||||
|
||||
**Deprecated/outdated in the PHP tree (not ported):** `StripeSubscriptionService`, pgsub `WebhookController`, pgsub's own `ProcessStripeSubscriptionWebhook`, `config/stripe.php` (D-10).
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | Laravel's `throttle:60,1` keys guests by IP across routes, so one shared 60/min bucket for both URLs matches PHP | Pattern 3 | A slightly different limit; low impact |
|
||||
| A2 | Stripe price search is eventually consistent, so `List` by lookup key is safer | Alternatives | None functional; only the choice of call |
|
||||
| A3 | Porting the usd/eur/pln loop faithfully is what the user wants (it creates extra prices for single-currency plans) | Pitfall 7 | Extra Stripe prices on sync; reversible in Stripe |
|
||||
| A4 | Stripe renders webhook payloads in the endpoint's configured API version, so both shapes occur in practice | Patterns 6, Pitfalls 2–3 | Low — reading both is harmless either way |
|
||||
| A5 | Customer-portal cancellations may arrive as `cancel_at` rather than `cancel_at_period_end` on current API versions | Open Question 3 | `reactivated` and "will cancel" display could be wrong for portal-initiated cancels |
|
||||
| A6 | Horoskopia's production `stripe.webhookSecret` is the subscriptions endpoint's secret (stated in its own controller comment; production values not inspected) | S-4 | Wrong secret at cutover → 400s until fixed in settings |
|
||||
| A7 | Omitting the dormant Quotify tables (S-3) is acceptable to the user | S-3 | D-04 amendment rejected → add two tables without Quotify FKs |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Amend D-01 for the Stripe sync source?** Known: plan sync exists only in `d00f850`/`d54dcde`. Recommendation: amend to "pgstripe `8b47457`, except `classes/StripeSubscriptionGateway.php` at `d00f850`".
|
||||
2. **Dormant tables (S-3) and "adopts unchanged" (S-2).** Recommendation: omit the two Quotify tables; restate D-04 as "import-compatible baseline"; defer the `HasWinterImport` mapper to Phase 20/the Horoskopia port.
|
||||
3. **`cancel_at` vs `cancel_at_period_end`.** Unclear whether current portals set `cancel_at`. Recommendation: keep PHP mapping; record `cancel_at` handling as a follow-up after a live test-mode portal cancel (human verification item).
|
||||
4. **Out-of-order events.** No column exists to store the last applied event time. Recommendation: parity now; revisit with real data.
|
||||
5. **Import mapping of the webhook secret (S-4).** Which default should `pgstripe:import-winter` use? Recommendation: an explicit flag, no silent default change to P18 behaviour.
|
||||
6. **Reorder page and `taglist` (S-6/S-7).** Recommendation: plugin field type + no reorder page now; two framework todos.
|
||||
7. **Customer `GET /subscription`: latest or active?** Horoskopia uses latest + `hasAccess`; the Winter component used active. Recommendation: latest, with `has_access` and `in_grace_period` flags so the client decides.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required by | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | all Go repos | ✓ | go1.27.0 | — |
|
||||
| Docker (testcontainers Postgres) | full test runs, host test | ✓ (`docker ps` works in this shell) | 29.7.2 | `sudo -u golem` per CLAUDE.md |
|
||||
| Node | layer tests | ✓ | v22.23.2 (≥ 22.18 required) | — |
|
||||
| pnpm | layer | ✓ | 11.3.0 | — |
|
||||
| `tea` | creating `sm-paymentgatewaysubscriptions-plugin` remote | ✓ | 0.16.0 | user creates it on git.golem15.com |
|
||||
| Stripe CLI | optional live test-mode UAT (`stripe listen`) | ✓ | — | dashboard webhook to a public URL |
|
||||
| PHP DB dump | schema diff | ✓ (Horoskopia `v2.1-pre-migrate-…211242Z.sql`) | MariaDB 12.2 | — |
|
||||
| QuestStream DB | second diff | ✗ | — | migrations only (done) |
|
||||
|
||||
**Missing with no fallback:** none. **Missing with fallback:** QuestStream DB (not needed).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` + testcontainers Postgres 16 (Docker tests skip under `-short`); layer: `node --test` |
|
||||
| Config file | per-repo `testmain_test.go` (copy P18's `sm-pgstripe-plugin/testmain_test.go`: `flag.Parse()` + `testing.Short()`) |
|
||||
| Quick run | `cd ../sm-paymentgatewaysubscriptions-plugin && go vet ./... && go test -short ./...`; `cd ../sm-pgstripe-plugin && go vet ./... && go test -short ./...` |
|
||||
| Full suite | the same with `go test ./...` (Docker); `cd ../vue-paymentgateway-layer && pnpm test`; framework unchanged: `go vet ./... && go test -short ./...` in `summercms.go` |
|
||||
| Phase gate | `scripts/check-phase18.1.sh` in `summercms.go` (P18 precedent `scripts/check-phase18.sh`) running the named tests in all three repos |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Req | Behaviour | Type | Automated command | File exists? |
|
||||
|-----|-----------|------|-------------------|--------------|
|
||||
| SC-1 | Baseline runs up/down, re-runs idempotently, names match the dump; users column added only if missing | Docker | `go test ./updates/ -run TestBaseline` (pgsub) | ❌ Wave 0 |
|
||||
| SC-1 | Registry: duplicate alias fails boot; unknown alias errors; `user` subscribable reads/writes `users.stripe_customer_id` | unit + Docker | `go test -short ./contracts/ -run TestRegistry`; `go test ./classes/ -run TestUserSubscribable` | ❌ |
|
||||
| SC-1 | Status/grace/feature/days-until-renewal matrices | unit | `go test -short ./models/ -run 'TestAccess|TestGrace|TestFeatures'` | ❌ |
|
||||
| SC-2 | Webhook: 500 no secret, 400 bad sig/payload, 200 duplicate (no job), 200 unhandled type (no job), 200 + one job for each of the five types, both URLs | Docker (needs DB for cheap-check) + `-short` decode tests | `go test ./subscriptions/ -run TestWebhook` (pgstripe) | ❌ |
|
||||
| SC-2 | Job: marker only on success (error → no marker, retry), skip paths write marker (STRIPE-007), duplicate job no second event, concurrent duplicates fire once, basil + pre-basil shapes | Docker | `go test ./subscriptions/ -run TestJob` | ❌ |
|
||||
| SC-2 | Event matrix: activated/past_due/canceled/reactivated per previous→current, deleted always canceled | unit on `Service.Apply` with a tx | `go test ./classes/ -run TestApplyEvents` (pgsub) | ❌ |
|
||||
| SC-2 | `SyncPlanToGateway`: free skip, product create/update, price unchanged/archive+create, `stripe_prices`/`stripe_price_id` stored | fake Stripe | `go test ./subscriptions/ -run TestSyncPlan` | ❌ |
|
||||
| SC-3 | Admin compile (YAML boots), bulk/toolbar sync results, preview partial | Docker smoke | `go test ./ -run TestAdmin` (pgsub) | ❌ |
|
||||
| SC-3 | Customer API authz (401 without JWT on subscription routes, plans public), 409/404/422 paths, redirect URLs from config only | Docker | `go test ./api/ -run TestCustomerAPI` | ❌ |
|
||||
| SC-3 | Layer client/components | node | `pnpm test` (layer) | ❌ new specs |
|
||||
| SC-4 | Host flow: checkout → created(active) → activated; invoice.paid; payment_failed + updated(past_due) → past_due, grace on/off by clock; cancel at period end → deleted → canceled; reactivation; duplicate idempotency; plan sync; one run through a real conga worker | Docker | `go test ./subscriptions/ -run TestHostSubscriptionFlow` | ❌ |
|
||||
| SC-5 | PHP test map covered (below) | — | phase gate script | ❌ |
|
||||
|
||||
### PHP tests → Go cases
|
||||
|
||||
| PHP test | Go case |
|
||||
|----------|---------|
|
||||
| `reliability/ReliabilityTest::test_stripe_006_idempotency_marker_after_job_success` (source checks: no marker in controller, cheap-check kept, marker after success, rethrow) | Behavioural: handler never inserts a marker; a failing `Apply` leaves no marker and returns an error (River retries); success leaves exactly one marker |
|
||||
| `…::test_stripe_006_idempotency_marker_double_processing_prevented` | A second job run for the same event id changes nothing and fires no event; two concurrent runs fire once |
|
||||
| `security/DataHandlingTest::test_pay_10_lazy_decrypt_handles_plaintext` (readEncryptedSetting) | `pgstripe:import-winter` maps plaintext and Laravel-encrypted subscription secrets into the new columns (extend P18's import test) |
|
||||
| `security/DataHandlingTest::test_stripe_001_*` (fields masked, encrypted at rest, round trip) | New columns are `lagoon.Encrypted`, `json:"-"`, `type: password` on the settings form; `meta.secrets` reports stored/env/empty (extend `settings_security_test.go`) |
|
||||
| `security/DataHandlingTest::test_stripe_003_sdk_constraint_pinned` | Already `TestSDKPin`; the subpackage adds no second stripe-go |
|
||||
| `security/AuthenticationTest` (signature required, valid accepted, invalid rejected) | `TestWebhook` signature subtests via `webhook.GenerateTestSignedPayload` or `ComputeSignature` |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** the `-short` quick run of the touched repo.
|
||||
- **Per wave merge:** full suite of the touched repos (Docker).
|
||||
- **Phase gate:** `scripts/check-phase18.1.sh` green before `/msd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `sm-paymentgatewaysubscriptions-plugin/testmain_test.go` + harness (copy P18 `testmain_test.go`, `harness_test.go`)
|
||||
- [ ] Subscription-endpoint Stripe fake (`internal/stripefake` or `subscriptions/stripefake_test.go`): `POST /v1/customers`, `POST /v1/checkout/sessions`, `POST /v1/billing_portal/sessions`, `GET|POST|DELETE /v1/subscriptions/{id}`, `POST /v1/products`, `POST /v1/products/{id}`, `GET /v1/prices`, `POST /v1/prices`, `POST /v1/prices/{id}`
|
||||
- [ ] Event-body builders for both API shapes (subscription and invoice)
|
||||
- [ ] `scripts/check-phase18.1.sh`
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS category | Applies | Standard control |
|
||||
|---------------|---------|------------------|
|
||||
| V2 Authentication | yes | Webhook: Stripe HMAC only (`VerifySignature`); customer API: user plugin `jwt` guard via `Authorization: Bearer`, never cookies (P18 `api/auth.go`) |
|
||||
| V3 Session Management | no | Stateless JWT; no new sessions |
|
||||
| V4 Access Control | yes | Customer API acts only on the token's user (`subscriber_type = 'user'`, `subscriber_id = jwt user id`); admin `manage_subscriptions`/`manage_plans`; sync actions additionally need `manage_plans` |
|
||||
| V5 Input Validation | yes | Minimal typed decode of webhook objects; plan form rules; customer API validates `plan_code`/`currency` against the DB and gateway |
|
||||
| V6 Cryptography | yes | `lagoon.Encrypted` for secrets; no hand-rolled crypto |
|
||||
| V7 Error/Logging | yes | Log ids and event types, never bodies, emails, invoice addresses or Stripe error text that may echo PII |
|
||||
| V13 API | yes | Throttle buckets on webhook and customer API; body size caps |
|
||||
|
||||
### Known Threat Patterns
|
||||
|
||||
| ID | Pattern | STRIDE | Mitigation |
|
||||
|----|---------|--------|------------|
|
||||
| T-1 | Forged webhook (public, CSRF-free URL) | Spoofing | HMAC on raw body with the mode's subscription secret; 400 on failure; never act before verification |
|
||||
| T-2 | Replay of a captured signed event | Tampering | 300 s tolerance in `ValidatePayload`; processed-event unique key; job claim-first |
|
||||
| T-3 | Duplicate/concurrent deliveries double-fire listeners (mails, grants) | Repudiation/Tampering | Claim row in-tx; events in-tx; listeners' side effects in `lagoon.AfterCommit` |
|
||||
| T-4 | Open redirect / phishing via checkout `success_url`, `cancel_url`, portal `return_url` | Spoofing | URLs from config only; never from request input |
|
||||
| T-5 | IDOR on customer API (cancel/portal for another subscriber) | Elevation | Subscriber derived from the JWT only; no id parameters |
|
||||
| T-6 | PII persisted in `river_job.args` (invoice `customer_email`, address) and logs | Information disclosure | Enqueue a minimal struct (ids, status, periods, amounts) not the raw object; log ids only |
|
||||
| T-7 | Secret leakage through admin/API | Information disclosure | `json:"-"`, write-only `type: password`, `meta.secrets` status only (P18) |
|
||||
| T-8 | Wrong-mode secret accepted (test event in live) | Spoofing | Mode-aware secret selection (`test_mode`), like `pgstripe.ModeKeys` |
|
||||
| T-9 | DoS through webhook floods or huge bodies | DoS | 60/min/IP bucket (PHP parity); `MaxBytesReader`; cheap-check before enqueue |
|
||||
| T-10 | Unknown `subscriber_type` from metadata creating orphan rows | Tampering | Only signed events reach the job; unknown alias → error (D-08) |
|
||||
| T-11 | Cross-app event processing on a shared Stripe account | Tampering | Configurable `metadata.app` filter (S-5) |
|
||||
| T-12 | Admin plan sync abused to spam Stripe objects | Tampering | `manage_plans` permission; action runs logged by cabana |
|
||||
|
||||
## Recommended Plan Split (lean; MVP tracer-first; tests last)
|
||||
|
||||
Present this at the plan-count checkpoint (CLAUDE.md rule 1).
|
||||
|
||||
1. **pgsub core tracer** — `sm-paymentgatewaysubscriptions-plugin` (new repo): bootstrap (go.mod with `replace` lines like pgstripe's, go.work, README skeleton, plugin ID, `Requires`), baseline + users migration, models and checks, contracts and registries (Boot collection), `UserSubscribable`, six typed events, `Service` (Apply with event diff, ClaimEvent/EventProcessed, ActivePlans, Latest/Active, Checkout, Portal, Cancel, SyncPlan, SyncSubscription), config `grace_period_days`; smoke test with a fake gateway on Postgres. Repo created with `tea repos create --owner golem15`; push only on go-ahead.
|
||||
2. **Stripe subscriptions + webhook + host proof** — `sm-pgstripe-plugin`: root additive changes (migration for the two secret columns, model fields, fields.yaml, `SecretFallbacks`, config keys, import-winter mapping, README); `subscriptions/` plugin `golem15.pgstripesubscriptions` (Gateway from `d00f850`, both routes + bucket, handler, River job with claim-first, app-tag config, fake, `TestHostSubscriptionFlow` incl. a real worker run). `go.mod` requires the pgsub module with `replace ../sm-paymentgatewaysubscriptions-plugin`. **Order:** plan 1's repo committed (and pushed if approved) before plan 2's `go.mod` points at it.
|
||||
3. **Admin screens + customer API** — pgsub: Subscriptions (list/filter/preview/partial/status type), SubscriptionPlans (form, features field type, bulk + toolbar sync), navigation, permissions, EN/PL lang, customer API with JWT, bucket and config redirect URLs; README complete (API reference, configuration, customer API table); smoke tests.
|
||||
4. **Layer** — `vue-paymentgateway-layer`: client, composable, components, runtime config, i18n, README, playground page; tag `v0.2.0` only on go-ahead.
|
||||
5. **Unit tests (last)** — all three repos: model matrices, registry, Apply/event matrix, webhook and job (STRIPE-006/007, concurrency, both API shapes), sync plan, customer API authz, import-winter secrets, layer specs; PHP test map; `scripts/check-phase18.1.sh`; README Testing sections.
|
||||
|
||||
No `summercms.go` code plan: every gap has a plugin-side workaround (S-6, S-7). If the user wants the flat reorder page or a core `taglist` now, insert a framework plan 0 (cabana + admin SPA + OpenAPI + `dist` + docs, framework tag at the phase gate as in P18).
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence, read this session)
|
||||
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/paymentgatewaysubscriptions/` at `a522376` (= `ac3d73c` + `5fb9dac`): `Plugin.php`, `contracts/*`, `models/*`, `models/*/{fields,columns}.yaml`, `controllers/*`, `http/routes.php`, `config/config.php`, `updates/*`, `traits/HasSubscriptions.php`, `components/SubscriptionManager.php`, `lang/en/lang.php`; `git show 5fb9dac`
|
||||
- `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/pgstripe/` at `8b47457` and `d54dcde` (`git diff`): `classes/StripeSubscriptionGateway.php`, `classes/StripeSettings.php`, `jobs/ProcessStripeSubscriptionWebhook.php`, `http/routes.php`, `http/controllers/StripeWebhookController.php`, `config/config.php`, `Plugin.php`, `tests/reliability/ReliabilityTest.php`, `tests/security/DataHandlingTest.php`
|
||||
- Horoskopia MariaDB dump `storage/db-backups/v2.1-pre-migrate-20260501T211242Z.sql` lines 5198106–5198215
|
||||
- Horoskopia consumer: `horoscope/routes.php` 30–43, 362–760; `Plugin.php` 330–505; `classes/SubscribableUserAdapter.php`; `console/ReconcileSubscriptions.php`; `controllers/HoroscopeStripeWebhookController.php` 19–44, 258–265; `updates/add_stripe_customer_id_to_users.php`
|
||||
- Siblings: `sm-pgstripe-plugin` (`plugin.go`, `client.go`, `webhook.go`, `models/settings.go`, `updates/*`, `console/import_winter.go`, `config/config.yaml`, `admin.go`, `testmain_test.go`, `host_test.go`, `stripefake_test.go`, `sdk_pin_test.go`, `go.mod`, `go.work`); `sm-paymentgateway-plugin` (`boot.go`, `operators/registry.go`, `money/money.go`, `events/events.go`, `routes.go`, `api/auth.go`, `api/errors.go`, `field_types.go`, `controllers/payments/*`, `updates/*`); `sm-summercmsio-app/plugins/golem15/user` (`models/user.go`, `updates/*`); `vue-paymentgateway-layer` (`package.json`, `nuxt.config.ts`, `app/utils/client.ts`, `app/composables/usePaymentGatewayApi.ts`, README outline, tags)
|
||||
- Framework: `modules/pact/capabilities.go`, `modules/cabana/{README.md,form_schema.go,list_schema.go,reorder.go,crud.go}`, `modules/conga/{README.md,conga.go}`, `modules/festival/README.md`, `modules/lagoon/{migrations.go,transaction.go,encrypted.go,laravel_decrypt.go,jsonable.go}`, `internal/build/{manifest.go,build.go}`
|
||||
- Planning: `18.1-CONTEXT.md`, `18-CONTEXT.md`, `18-VERIFICATION.md`, `18-RESEARCH.md` (F-4, test map), `18-0x-SUMMARY.md` (repo creation via `tea`), `20-cutover/20-CONTEXT.md` (D-01/D-02), `05-…-CONTEXT.md` (D-01), notes `core-plugins-own-repos.md`, `plugin-layout-winter-directories.md`
|
||||
- stripe-go v87.0.0 module cache: `api_version.go` (`2026-09-30.endive`), `invoice.go` (`InvoiceParentSubscriptionDetails`), `subscription.go`, `price.go` (`PriceListParams.LookupKeys`, `TransferLookupKey`), `stripe_client.go` (`V1*` services), `webhook/client.go`
|
||||
|
||||
### Secondary (MEDIUM)
|
||||
- [Stripe changelog: adds new parent field to invoicing objects (basil 2025-03-31)](https://docs.stripe.com/changelog/basil/2025-03-31/adds-new-parent-field-to-invoicing-objects)
|
||||
- [Stripe changelog: cancel at enums (basil 2025-05-28)](https://docs.stripe.com/changelog/basil/2025-05-28/cancel-at-enums)
|
||||
- [Stripe changelog: cancel at enums (basil 2025-07-30)](https://docs.stripe.com/changelog/basil/2025-07-30/cancel-at-enums)
|
||||
- [Stripe changelog: scheduled subscription cancellation (endive 2026-09-30)](https://docs.stripe.com/changelog/endive/2026-09-30/scheduled-subscription-cancellation)
|
||||
- [Stripe docs: subscription invoices](https://docs.stripe.com/billing/invoices/subscription?lang=curl)
|
||||
- [Stripe docs: cancel subscriptions](https://docs.stripe.com/billing/subscriptions/cancel)
|
||||
|
||||
### Tertiary (LOW)
|
||||
- Training knowledge on Laravel throttle keys and Stripe search consistency (A1, A2)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- PHP contract, schema, events, consumer map: HIGH — every file read this session, schema diffed against a real dump
|
||||
- Phase 18 reuse and framework gaps: HIGH — sibling and framework code read at the verified heads
|
||||
- Stripe API-version behaviour: MEDIUM — changelog plus SDK structs; payload-version rendering assumed (A4)
|
||||
- Plan split: MEDIUM — follows P18 precedent; final count is the user's call
|
||||
|
||||
**Research date:** 2026-10-14
|
||||
**Valid until:** 2026-11-13 (stable sources; re-check if the Horoskopia PHP checkouts move or Phase 20 builds the import runner first)
|
||||
Reference in New Issue
Block a user