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