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):