Commit graph

5 commits

Author SHA1 Message Date
senke
7e26a8dd1f feat(subscription): recovery endpoint + distribution gate (v1.0.9 item G — Phase 3)
Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 4m19s
Security Scan / Secret Scanning (gitleaks) (push) Successful in 1m4s
Veza CI / Frontend (Web) (push) Failing after 16m42s
Veza CI / Backend (Go) (push) Failing after 19m28s
Veza CI / Notify on failure (push) Successful in 15s
E2E Playwright / e2e (full) (push) Failing after 19m56s
Phase 3 closes the loop on Item G's pending_payment state machine:
the user-facing recovery path for stalled paid-plan subscriptions, and
the distribution gate that surfaces a "complete payment" hint instead
of the generic "upgrade your plan".

Recovery endpoint — POST /api/v1/subscriptions/complete/:id

  Re-fetches the PSP client_secret for a subscription stuck in
  StatusPendingPayment so the SPA can drive the payment UI to
  completion. The PSP CreateSubscriptionPayment call is idempotent on
  sub.ID.String() (same idempotency key as Phase 1), so hitting this
  endpoint repeatedly returns the same payment intent rather than
  creating a duplicate.

  Maps to:
    - 200 + {subscription, client_secret, payment_id} on success
    - 404 if the subscription doesn't belong to caller (avoids ID leak)
    - 409 if the subscription is not in pending_payment (already
      activated by webhook, manual admin action, plan upgrade, etc.)
    - 503 if HYPERSWITCH_ENABLED=false (mirrors Subscribe's fail-closed
      behaviour from Phase 1)

  Service surface:
    - subscription.GetPendingPaymentSubscription(ctx, userID) — returns
      the most-recently-created pending row, used by both the recovery
      flow and the distribution gate probe
    - subscription.CompletePendingPayment(ctx, userID, subID) — the
      actual recovery call, returns the same SubscribeResponse shape as
      Phase 1's Subscribe endpoint
    - subscription.ErrSubscriptionNotPending — sentinel for the 409
    - subscription.ErrSubscriptionPendingPayment — sentinel propagated
      out of distribution.checkEligibility

Distribution gate — distinct path for pending_payment

  Before: a creator with only a pending_payment row hit
  ErrNoActiveSubscription → distribution surfaced the generic
  ErrNotEligible "upgrade your plan" error. Confusing because the
  user *did* try to subscribe — they just hadn't completed the payment.

  After: distribution.checkEligibility probes for a pending_payment row
  on the ErrNoActiveSubscription branch and returns
  ErrSubscriptionPendingPayment. The handler maps this to a 403 with
  "Complete the payment to enable distribution." so the SPA can route
  to the recovery page instead of the upgrade page.

Tests (11 new, all green via sqlite in-memory):
  internal/core/subscription/recovery_test.go (4 tests / 9 subtests)
    - GetPendingPaymentSubscription: no row / active row invisible /
      pending row + plan preload / multiple pending rows pick newest
    - CompletePendingPayment: happy path + idempotency key threaded /
      ownership mismatch → ErrSubscriptionNotFound /
      not-pending → ErrSubscriptionNotPending /
      no provider → ErrPaymentProviderRequired /
      provider error wrapping
  internal/core/distribution/eligibility_test.go (2 tests)
    - Submit_EligibilityGate_PendingPayment: pending_payment user
      gets ErrSubscriptionPendingPayment (recovery hint)
    - Submit_EligibilityGate_NoSubscription: no-sub user gets
      ErrNotEligible (upgrade hint), NOT the recovery branch

E2E test (28-subscription-pending-payment.spec.ts) deferred — needs
Docker infra running locally to exercise the webhook signature path,
will land alongside the next CI E2E pass.

TODO removal: the roadmap mentioned a `TODO(v1.0.7-item-G)` in
subscription/service.go to remove. Verified none present
(`grep -n TODO internal/core/subscription/service.go` → 0 hits).
Acceptance criterion trivially met.

SKIP_TESTS=1 rationale: backend-only Go changes, frontend hooks
irrelevant. All Go tests verified manually:

  $ go test -short -count=1 ./internal/core/subscription/... \
      ./internal/core/distribution/... ./internal/core/marketplace/... \
      ./internal/services/hyperswitch/... ./internal/handlers/...
  ok  veza-backend-api/internal/core/subscription
  ok  veza-backend-api/internal/core/distribution
  ok  veza-backend-api/internal/core/marketplace
  ok  veza-backend-api/internal/services/hyperswitch
  ok  veza-backend-api/internal/handlers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:33:40 +02:00
senke
2a96766ae3 feat(subscription): pending_payment state machine + mandatory provider (v1.0.9 item G — Phase 1)
First instalment of Item G from docs/audit-2026-04/v107-plan.md §G.
This commit lands the state machine + create-flow change. Phase 2
(webhook handler + recovery endpoint + reconciler sweep) follows.

What changes :
  - **`models.go`** — adds `StatusPendingPayment` to the
    SubscriptionStatus enum. Free-text VARCHAR(30) so no DDL needed
    for the value itself; Phase 2's reconciler index lives in
    migration 986 (additive, partial index on `created_at` WHERE
    status='pending_payment').
  - **`service.go`** — `PaymentProvider.CreateSubscriptionPayment`
    interface gains an `idempotencyKey string` parameter, mirroring
    the marketplace.refundProvider contract added in v1.0.7 item D.
    Callers pass the new subscription row's UUID so a retried HTTP
    request collapses to one PSP charge instead of duplicating it.
  - **`createNewSubscription`** — refactored state machine :
      * Free plan → StatusActive (unchanged, in subscribeToFreePlan).
      * Paid plan, trial available, first-time user → StatusTrialing,
        no PSP call (no invoice either — Phase 2 will create the
        first paid invoice on trial expiry).
      * Paid plan, no trial / repeat user → **StatusPendingPayment**
        + invoice + PSP CreateSubscriptionPayment with idempotency
        key = subscription.ID.String(). Webhook
        subscription.payment_succeeded (Phase 2) flips to active;
        subscription.payment_failed flips to expired.
  - **`if s.paymentProvider != nil` short-circuit removed**. Paid
    plans now require a configured PaymentProvider — without one,
    `createNewSubscription` returns ErrPaymentProviderRequired. The
    handler maps this to HTTP 503 "Payment provider not configured —
    paid plans temporarily unavailable", surfacing env misconfig to
    ops instead of silently giving away paid plans (the v1.0.6.2
    fantôme bug class).
  - **`GetUserSubscription` query unchanged** — already filters on
    `status IN ('active','trialing')`, so pending_payment rows
    correctly read as "no active subscription" for feature-gate
    purposes. The v1.0.6.2 hasEffectivePayment filter is kept as
    defence-in-depth for legacy rows.
  - **`hyperswitch.Provider`** — implements
    `subscription.PaymentProvider` by delegating to the existing
    `CreatePaymentSimple`. Compile-time interface assertion added
    (`var _ subscription.PaymentProvider = (*Provider)(nil)`).
  - **`routes_subscription.go`** — wires the Hyperswitch provider
    into `subscription.NewService` when HyperswitchEnabled +
    HyperswitchAPIKey + HyperswitchURL are all set. Without those,
    the service falls back to no-provider mode (paid subscribes
    return 503).
  - **Tests** : new TestSubscribe_PendingPaymentStateMachine in
    gate_test.go covers all five visible outcomes (free / paid+
    provider / paid+no-provider / first-trial / repeat-trial) with a
    fakePaymentProvider that records calls. Asserts on idempotency
    key = subscription.ID.String(), PSP call counts, and the
    Subscribe response shape (client_secret + payment_id surfaced).
    5/5 green, sqlite :memory:.

Phase 2 backlog (next session) :
  - `ProcessSubscriptionWebhook(ctx, payload)` — flip pending_payment
    → active on success / expired on failure, idempotent against
    replays.
  - Recovery endpoint `POST /api/v1/subscriptions/complete/:id` —
    return the existing client_secret to resume a stalled flow.
  - Reconciliation sweep for rows stuck in pending_payment past the
    webhook-arrival window (uses the new partial index from
    migration 986).
  - Distribution.checkEligibility explicit pending_payment branch
    (today it's already handled implicitly via the active/trialing
    filter).
  - E2E @critical : POST /subscribe → POST /distribution/submit
    asserts 403 with "complete payment" until webhook fires.

Backward compat : clients on the previous flow that called
/subscribe expecting an immediately-active row will now see
status=pending_payment + a client_secret. They must drive the PSP
confirm step before the row is granted feature access. The
v1.0.6.2 voided_subscriptions cleanup migration (980) handles
pre-existing fantôme rows.

go build ./... clean. Subscription + handlers test suites green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:02:00 +02:00
senke
9a8d2a4e73 chore(release): v1.0.6.2 — subscription payment-gate bypass hotfix
Closes a bypass surfaced by the 2026-04 audit probe (axis-1 Q2): any
authenticated user could POST /api/v1/subscriptions/subscribe on a paid
plan and receive 201 active without the payment provider ever being
invoked. The resulting row satisfied `checkEligibility()` in the
distribution service via `can_sell_on_marketplace=true` on the Creator
plan — effectively free access to /api/v1/distribution/submit, which
dispatches to external partners.

Fix is centralised in `GetUserSubscription` so there is no code path
that can grant subscription-gated access without routing through the
payment check. Effective-payment = free plan OR unexpired trial OR
invoice with non-empty hyperswitch_payment_id. Migration 980 sweeps
pre-existing fantôme rows into `expired`, preserving the tuple in a
dated audit table for support outreach.

Subscribe and subscribeToFreePlan treat the new ErrSubscriptionNoPayment
as equivalent to ErrNoActiveSubscription so re-subscription works
cleanly post-cleanup. GET /me/subscription surfaces needs_payment=true
with a support-contact message rather than a misleading "you're on
free" or an opaque 500. TODO(v1.0.7-item-G) annotation marks where the
`if s.paymentProvider != nil` short-circuit needs to become a mandatory
pending_payment state.

Probe script `scripts/probes/subscription-unpaid-activation.sh` kept as
a versioned regression test — dry-run by default, --destructive logs in
and attempts the exploit against a live backend with automatic cleanup.
8-case unit test matrix covers the full hasEffectivePayment predicate.

Smoke validated end-to-end against local v1.0.6.2: POST /subscribe
returns 201 (by design — item G closes the creation path), but
GET /me/subscription returns subscription=null + needs_payment=true,
distribution eligibility returns false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:21:53 +02:00
senke
c0e2fe2e12 fix(v0.12.6.1): remediate remaining 15 MEDIUM + LOW pentest findings
MEDIUM-002: Remove manual X-Forwarded-For parsing in metrics_protection.go,
  use c.ClientIP() only (respects SetTrustedProxies)
MEDIUM-003: Pin ClamAV Docker image to 1.4 across all compose files
MEDIUM-004: Add clampLimit(100) to 15+ handlers that parsed limit directly
MEDIUM-006: Remove unsafe-eval from CSP script-src on Swagger routes
MEDIUM-007: Pin all GitHub Actions to SHA in 11 workflow files
MEDIUM-008: Replace rabbitmq:3-management-alpine with rabbitmq:3-alpine in prod
MEDIUM-009: Add trial-already-used check in subscription service
MEDIUM-010: Add 60s periodic token re-validation to WebSocket connections
MEDIUM-011: Mask email in auth handler logs with maskEmail() helper
MEDIUM-012: Add k-anonymity threshold (k=5) to playback analytics stats
LOW-001: Align frontend password policy to 12 chars (matching backend)
LOW-003: Replace deprecated dotenv with dotenvy crate in Rust stream server
LOW-004: Enable xpack.security in Elasticsearch dev/local compose files
LOW-005: Accept context.Context in CleanupExpiredSessions instead of Background()
LOW-002: Noted — Hyperswitch version update deferred (requires payment integration tests)

29/30 findings remediated. 1 noted (LOW-002).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:13:38 +01:00
senke
f6ca52c3dc feat(v0.12.1): subscription plans service, handler, and routes
- Add subscription module (models, service, tests)
- Plans: Free, Creator ($9.99/mo), Premium ($19.99/mo)
- Features: subscribe, cancel, reactivate, change billing cycle
- 14-day trial for Premium plan
- Upgrade immediate, downgrade at period end
- Invoice tracking and history
- Handler tests for auth and validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:36:57 +01:00