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>
13 lines
435 B
SQL
13 lines
435 B
SQL
-- Rollback for v1.0.6.2 void-unpaid cleanup.
|
|
-- Restores the previous status for every row recorded in the audit table,
|
|
-- then drops the audit table itself.
|
|
|
|
UPDATE user_subscriptions us
|
|
SET status = vs.previous_status,
|
|
canceled_at = NULL,
|
|
updated_at = NOW()
|
|
FROM voided_subscriptions_20260417 vs
|
|
WHERE us.id = vs.subscription_id
|
|
AND us.status = 'expired';
|
|
|
|
DROP TABLE IF EXISTS voided_subscriptions_20260417;
|