From 26cb523334e46289780b75ec8cec949702fe3792 Mon Sep 17 00:00:00 2001 From: senke Date: Fri, 17 Apr 2026 12:43:21 +0200 Subject: [PATCH] fix(distribution,audit): propagate ErrSubscriptionNoPayment to handler + P0.12 closure date + E2E regression TODO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of the v1.0.6.2 hotfix surfaced that distribution.checkEligibility silently swallowed subscription.ErrSubscriptionNoPayment as "ineligible, no extra info", so a user with a fantôme subscription trying to submit a distribution got "Distribution requires Creator or Premium plan" — misleading, the user has a plan but no payment. checkEligibility now propagates the error so the handler can surface "Your subscription is not linked to a payment. Complete payment to enable distribution." Security is unchanged — the gate still refuses. This is a UX clarity fix for honest-path users who landed in the fantôme state via a broken payment flow. Also: - Closure timestamp added to axis-1 P0.12 ("closed 2026-04-17 in v1.0.6.2 (commit 9a8d2a4e7)") so future readers know the finding's lifecycle without re-grepping the CHANGELOG. - Item G in v107-plan.md gains an explicit E2E Playwright @critical acceptance — the shell probe + Go unit tests validate the fix today but don't run on every commit, so a refactor of Subscribe or checkEligibility could silently re-open the bypass. The E2E test makes regression coverage automatic. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/audit-2026-04/axis-1-correctness.md | 4 ++-- docs/audit-2026-04/v107-plan.md | 7 +++++++ .../internal/core/distribution/service.go | 14 ++++++++++---- .../internal/handlers/distribution_handler.go | 6 ++++++ 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/audit-2026-04/axis-1-correctness.md b/docs/audit-2026-04/axis-1-correctness.md index 13cd496f1..734e3729b 100644 --- a/docs/audit-2026-04/axis-1-correctness.md +++ b/docs/audit-2026-04/axis-1-correctness.md @@ -423,8 +423,8 @@ can_sell_on_marketplace` check on paid plans. a mandatory `pending_payment` state + webhook-driven activation (item G in the v1.0.7 plan). -**Criticity** — **P0, closed in v1.0.6.2.** Item G in v1.0.7 hardens -the creation path end-to-end. +**Criticity** — **P0, closed 2026-04-17 in v1.0.6.2 (commit +d31f5733d).** Item G in v1.0.7 hardens the creation path end-to-end. --- diff --git a/docs/audit-2026-04/v107-plan.md b/docs/audit-2026-04/v107-plan.md index 9a65395c1..4bde974e2 100644 --- a/docs/audit-2026-04/v107-plan.md +++ b/docs/audit-2026-04/v107-plan.md @@ -255,6 +255,13 @@ Acceptance: - Subscribe with provider misconfigured → 503, no row created. - Migration of v1.0.6.2 voided rows — check `voided_subscriptions_20260417` entries stay readable and not re-pickable by the new flow. + - **E2E Playwright @critical**: `POST /subscribe` followed by + `POST /distribution/submit` asserts 403 with the "complete + payment" message until the payment webhook fires. Today's + regression coverage is the shell probe + Go unit tests — + neither runs on every commit. Wiring a Playwright @critical + test turns the probe into a gate so a refactor of `Subscribe` + or `checkEligibility` cannot silently re-open the bypass. Independent of A/B/C/D/E/F. Can land at any point after D. diff --git a/veza-backend-api/internal/core/distribution/service.go b/veza-backend-api/internal/core/distribution/service.go index 505a85c7a..5e06da9e5 100644 --- a/veza-backend-api/internal/core/distribution/service.go +++ b/veza-backend-api/internal/core/distribution/service.go @@ -244,12 +244,18 @@ func (s *Service) checkEligibility(ctx context.Context, userID uuid.UUID) (bool, sub, err := s.subscriptionService.GetUserSubscription(ctx, userID) if err != nil { - // v1.0.6.2: ErrSubscriptionNoPayment means a row exists in - // active/trialing but has no effective payment linkage. Treat as - // ineligible, same blast radius as no subscription at all. - if errors.Is(err, subscription.ErrNoActiveSubscription) || errors.Is(err, subscription.ErrSubscriptionNoPayment) { + // No subscription row: ineligible with no extra signal — handler + // surfaces the standard "Creator or Premium plan required" message. + if errors.Is(err, subscription.ErrNoActiveSubscription) { return false, nil } + // v1.0.6.2: propagate ErrSubscriptionNoPayment so the handler can + // surface a distinct message ("complete payment") instead of the + // generic "upgrade your plan" — the user has a plan, just no + // effective payment linkage. + if errors.Is(err, subscription.ErrSubscriptionNoPayment) { + return false, err + } return false, err } diff --git a/veza-backend-api/internal/handlers/distribution_handler.go b/veza-backend-api/internal/handlers/distribution_handler.go index 5d5fa4bf8..875a098a6 100644 --- a/veza-backend-api/internal/handlers/distribution_handler.go +++ b/veza-backend-api/internal/handlers/distribution_handler.go @@ -7,6 +7,7 @@ import ( "time" "veza-backend-api/internal/core/distribution" + "veza-backend-api/internal/core/subscription" apperrors "veza-backend-api/internal/errors" "github.com/gin-gonic/gin" @@ -46,6 +47,11 @@ func (h *DistributionHandler) Submit(c *gin.Context) { switch { case errors.Is(err, distribution.ErrNotEligible): RespondWithAppError(c, apperrors.NewForbiddenError("Distribution requires Creator or Premium plan")) + case errors.Is(err, subscription.ErrSubscriptionNoPayment): + // v1.0.6.2: the user has a plan but no effective payment + // linkage. Distinct from ErrNotEligible so the UX can tell + // them to complete payment rather than upgrade. + RespondWithAppError(c, apperrors.NewForbiddenError("Your subscription is not linked to a payment. Complete payment to enable distribution.")) case errors.Is(err, distribution.ErrTrackNotPublic): RespondWithAppError(c, apperrors.NewValidationError("Track must be public and belong to you")) case errors.Is(err, distribution.ErrAlreadyDistributed):