veza/veza-backend-api/migrations/980_void_unpaid_subscriptions.sql

61 lines
2.3 KiB
MySQL
Raw Normal View History

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 10:21:53 +00:00
-- v1.0.6.2: Void subscription rows that sit in active/trialing state without
-- any effective payment linkage. Introduced to compensate for a bypass where
-- POST /subscribe could create 'active' rows on paid plans without invoking
-- the payment provider (e.g., when HYPERSWITCH_ENABLED=false or provider
-- unset). The runtime filter in GetUserSubscription (service.go) closes the
-- feature bypass going forward; this migration cleans up the rows already
-- written to the database pre-v1.0.6.2.
--
-- Fantôme selection criteria:
-- 1. status IN ('active', 'trialing')
-- 2. plan is paid (subscription_plans.price_monthly_cents > 0)
-- 3. no invoice attached carries a hyperswitch_payment_id (= PSP never reached)
-- 4. not a currently-valid trial (trial_end > NOW())
--
-- Audit table is dated so a future rerun doesn't collide. Rows here can be
-- used to notify affected users (if any were honest-path).
CREATE TABLE IF NOT EXISTS voided_subscriptions_20260417 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subscription_id UUID NOT NULL,
user_id UUID NOT NULL,
plan_id UUID NOT NULL,
previous_status VARCHAR(30) NOT NULL,
voided_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_voided_subscriptions_20260417_user
ON voided_subscriptions_20260417(user_id);
INSERT INTO voided_subscriptions_20260417 (subscription_id, user_id, plan_id, previous_status)
SELECT us.id, us.user_id, us.plan_id, us.status
FROM user_subscriptions us
JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.status IN ('active', 'trialing')
AND sp.price_monthly_cents > 0
AND NOT EXISTS (
SELECT 1
FROM subscription_invoices si
WHERE si.subscription_id = us.id
AND si.hyperswitch_payment_id IS NOT NULL
AND si.hyperswitch_payment_id <> ''
)
AND NOT (
us.status = 'trialing'
AND us.trial_end IS NOT NULL
AND us.trial_end > NOW()
);
UPDATE user_subscriptions
SET status = 'expired',
canceled_at = COALESCE(canceled_at, NOW()),
updated_at = NOW()
WHERE id IN (SELECT subscription_id FROM voided_subscriptions_20260417);
DO $$
DECLARE v_count INTEGER;
BEGIN
SELECT COUNT(*) INTO v_count FROM voided_subscriptions_20260417;
RAISE NOTICE 'v1.0.6.2: voided % pre-existing unpaid subscription row(s)', v_count;
END $$;