feat(billing): pilot user backfill — set existing accounts to complimentary
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
"""subscriptions pilot complimentary backfill
|
||||||
|
|
||||||
|
This migration converts existing pilot/dev accounts to permanent complimentary
|
||||||
|
Pro per the self-serve signup spec section 5. Forward-only; downgrade is
|
||||||
|
prohibited because original status is not preserved.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "c6cbfc534fad"
|
||||||
|
down_revision: Union[str, None] = "c982a3fc4bf1"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Set status='complimentary' and plan='pro' for all existing accounts that
|
||||||
|
don't have a canceled or past_due subscription. Pilot users transition to
|
||||||
|
permanent complimentary Pro per spec section 5.
|
||||||
|
|
||||||
|
Forward-only — does not preserve original status values."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
# Update existing rows
|
||||||
|
conn.execute(sa.text("""
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET status = 'complimentary', plan = 'pro',
|
||||||
|
current_period_end = NULL, current_period_start = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE status NOT IN ('canceled', 'past_due')
|
||||||
|
"""))
|
||||||
|
# Backfill: any account without a Subscription row gets one
|
||||||
|
conn.execute(sa.text("""
|
||||||
|
INSERT INTO subscriptions (id, account_id, plan, status, cancel_at_period_end, created_at, updated_at)
|
||||||
|
SELECT gen_random_uuid(), a.id, 'pro', 'complimentary', false, now(), now()
|
||||||
|
FROM accounts a
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.account_id = a.id)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot downgrade: original subscription state is not preserved. "
|
||||||
|
"Restore from backup if needed."
|
||||||
|
)
|
||||||
85
backend/tests/test_pilot_complimentary_backfill.py
Normal file
85
backend/tests/test_pilot_complimentary_backfill.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Smoke test for the complimentary backfill: assertions about the post-state.
|
||||||
|
The actual migration runs at deploy time; tests use create_all so the
|
||||||
|
migration body isn't executed automatically. We invoke the SQL inline to
|
||||||
|
exercise the same effect."""
|
||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select, text, delete
|
||||||
|
from app.models.account import Account
|
||||||
|
from app.models.subscription import Subscription
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_complimentary_backfill_sets_status_and_inserts_missing_rows(test_db):
|
||||||
|
"""Inline-run the backfill SQL and assert post-state."""
|
||||||
|
# Seed a fresh account with no subscription
|
||||||
|
no_sub_account = Account(name="NoSub", display_code="NOSUB001")
|
||||||
|
test_db.add(no_sub_account)
|
||||||
|
await test_db.flush()
|
||||||
|
|
||||||
|
# Seed an account with a trialing subscription (should become complimentary)
|
||||||
|
trial_account = Account(name="Trial", display_code="TRIAL001")
|
||||||
|
test_db.add(trial_account)
|
||||||
|
await test_db.flush()
|
||||||
|
test_db.add(Subscription(
|
||||||
|
account_id=trial_account.id, plan="free", status="trialing",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Seed an account with a canceled subscription (should be preserved)
|
||||||
|
canceled_account = Account(name="Cancel", display_code="CANCL001")
|
||||||
|
test_db.add(canceled_account)
|
||||||
|
await test_db.flush()
|
||||||
|
test_db.add(Subscription(
|
||||||
|
account_id=canceled_account.id, plan="pro", status="canceled",
|
||||||
|
))
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
# Run the same SQL the migration runs
|
||||||
|
await test_db.execute(text("""
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET status = 'complimentary', plan = 'pro',
|
||||||
|
current_period_end = NULL, current_period_start = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE status NOT IN ('canceled', 'past_due')
|
||||||
|
"""))
|
||||||
|
await test_db.execute(text("""
|
||||||
|
INSERT INTO subscriptions (id, account_id, plan, status, cancel_at_period_end, created_at, updated_at)
|
||||||
|
SELECT gen_random_uuid(), a.id, 'pro', 'complimentary', false, now(), now()
|
||||||
|
FROM accounts a
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.account_id = a.id)
|
||||||
|
"""))
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
# All three accounts now have a Subscription
|
||||||
|
no_sub_row = (await test_db.execute(
|
||||||
|
select(Subscription).where(Subscription.account_id == no_sub_account.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert no_sub_row.status == "complimentary"
|
||||||
|
assert no_sub_row.plan == "pro"
|
||||||
|
|
||||||
|
trial_row = (await test_db.execute(
|
||||||
|
select(Subscription).where(Subscription.account_id == trial_account.id)
|
||||||
|
)).scalar_one()
|
||||||
|
assert trial_row.status == "complimentary"
|
||||||
|
assert trial_row.plan == "pro"
|
||||||
|
|
||||||
|
canceled_row = (await test_db.execute(
|
||||||
|
select(Subscription).where(Subscription.account_id == canceled_account.id)
|
||||||
|
)).scalar_one()
|
||||||
|
# Canceled is preserved
|
||||||
|
assert canceled_row.status == "canceled"
|
||||||
|
assert canceled_row.plan == "pro"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_complimentary_subscription_passes_active_subscription_guard(
|
||||||
|
client, test_db, test_user, auth_headers
|
||||||
|
):
|
||||||
|
"""The require_active_subscription guard accepts complimentary status."""
|
||||||
|
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||||
|
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
|
||||||
|
test_db.add(Subscription(account_id=account_id, plan="pro", status="complimentary"))
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
response = await client.get("/api/v1/trees", headers=auth_headers)
|
||||||
|
assert response.status_code != 402
|
||||||
Reference in New Issue
Block a user