Compare commits
29 Commits
8494366ec6
...
feat/self-
| Author | SHA1 | Date | |
|---|---|---|---|
| f85b90c95e | |||
| 5e6541ab92 | |||
| 4a37a47887 | |||
| f31b873459 | |||
| 380fcf7bde | |||
| 4b098deac5 | |||
| 502c0a44e8 | |||
| 06200fabb1 | |||
| 3630dd5a80 | |||
| 5e0c9d2de1 | |||
| fee4cb5b74 | |||
| c75ce0c9a3 | |||
| db2478dd89 | |||
| 67fae91087 | |||
| 0c326d0616 | |||
| 99343ab7a9 | |||
| 53dd5f13e5 | |||
| 9b517d3320 | |||
| 7d939a4acf | |||
| 39e85c9770 | |||
| 70ab1f34d4 | |||
| ece82225f2 | |||
| 0b5ed9aa10 | |||
| 7a9cb4b03b | |||
| 80baf89b00 | |||
| d05b475a41 | |||
| 694279f89e | |||
| 16f5e4ce05 | |||
| 2f8ec3775e |
10
.env.example
10
.env.example
@@ -1,10 +0,0 @@
|
|||||||
REPO_ROOT=/opt/docker/code-server/workspace/resolutionflow
|
|
||||||
POSTGRES_PORT=5433
|
|
||||||
SECRET_KEY=
|
|
||||||
ANTHROPIC_API_KEY=
|
|
||||||
GOOGLE_AI_API_KEY=
|
|
||||||
|
|
||||||
STRIPE_SECRET_KEY=sk_test_
|
|
||||||
STRIPE_PUBLISHABLE_KEY=pk_test_
|
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_
|
|
||||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_
|
|
||||||
@@ -29,14 +29,4 @@ CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
|
|||||||
# When unset, app/core/config.py:stripe_enabled returns False and Stripe code paths short-circuit.
|
# When unset, app/core/config.py:stripe_enabled returns False and Stripe code paths short-circuit.
|
||||||
STRIPE_SECRET_KEY=sk_test_
|
STRIPE_SECRET_KEY=sk_test_
|
||||||
STRIPE_PUBLISHABLE_KEY=pk_test_
|
STRIPE_PUBLISHABLE_KEY=pk_test_
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_
|
STRIPE_WEBHOOK_SECRET=whsec_
|
||||||
|
|
||||||
# Self-serve cutover
|
|
||||||
# SELF_SERVE_ENABLED is the master switch for the public self-serve signup
|
|
||||||
# flow (pricing page, invite-code-optional registration). Default is false
|
|
||||||
# until Phase O cutover.
|
|
||||||
# INTERNAL_TESTER_EMAILS is a comma-separated allowlist that bypasses the
|
|
||||||
# global flag for specific users — used for prod test-mode validation
|
|
||||||
# before the public flip. Empty by default.
|
|
||||||
SELF_SERVE_ENABLED=false
|
|
||||||
INTERNAL_TESTER_EMAILS=
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
"""add_starter_rename_team_to_enterprise
|
|
||||||
|
|
||||||
Revision ID: 4ce3e594cb87
|
|
||||||
Revises: c6cbfc534fad
|
|
||||||
Create Date: 2026-05-07 19:36:27.172082
|
|
||||||
|
|
||||||
Plan tier taxonomy reconciliation. Marketing surface and Stripe products
|
|
||||||
named "Starter / Pro / Enterprise"; backend was on "free / pro / team".
|
|
||||||
This migration:
|
|
||||||
|
|
||||||
1. Defensively migrates any existing subscriptions on plan='team' to
|
|
||||||
plan='enterprise' (dev has zero such rows; prod is expected to have
|
|
||||||
none, but the UPDATE is safe and idempotent).
|
|
||||||
2. Renames the plan_limits row 'team' -> 'enterprise'. plan_billing
|
|
||||||
and plan_feature_defaults are FK-referenced but currently empty;
|
|
||||||
the rename works because PostgreSQL allows updating PK values when
|
|
||||||
no FK rows reference them.
|
|
||||||
3. Inserts a new plan_limits row for 'starter' between free and pro.
|
|
||||||
|
|
||||||
Resource visibility (Tree.visibility, StepLibrary.visibility) also uses
|
|
||||||
the string 'team' for "shared with my account" — that is a separate
|
|
||||||
domain and is intentionally not touched.
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
revision: str = '4ce3e594cb87'
|
|
||||||
down_revision: Union[str, None] = 'c6cbfc534fad'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.execute("UPDATE subscriptions SET plan = 'enterprise' WHERE plan = 'team'")
|
|
||||||
op.execute("UPDATE plan_limits SET plan = 'enterprise' WHERE plan = 'team'")
|
|
||||||
op.execute("""
|
|
||||||
INSERT INTO plan_limits (
|
|
||||||
plan,
|
|
||||||
max_trees,
|
|
||||||
max_sessions_per_month,
|
|
||||||
max_users,
|
|
||||||
custom_branding,
|
|
||||||
priority_support,
|
|
||||||
export_formats,
|
|
||||||
max_ai_builds_per_month,
|
|
||||||
max_ai_builds_per_24h,
|
|
||||||
kb_accelerator_enabled,
|
|
||||||
kb_max_lifetime_conversions,
|
|
||||||
kb_batch_max_size,
|
|
||||||
kb_allowed_formats,
|
|
||||||
kb_detailed_analysis,
|
|
||||||
kb_conversational_refinement,
|
|
||||||
kb_step_library_matching,
|
|
||||||
kb_history_limit
|
|
||||||
) VALUES (
|
|
||||||
'starter',
|
|
||||||
10,
|
|
||||||
75,
|
|
||||||
1,
|
|
||||||
FALSE,
|
|
||||||
FALSE,
|
|
||||||
'["markdown", "text", "html"]'::jsonb,
|
|
||||||
15,
|
|
||||||
5,
|
|
||||||
FALSE,
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
'["txt", "paste", "md"]'::jsonb,
|
|
||||||
FALSE,
|
|
||||||
FALSE,
|
|
||||||
FALSE,
|
|
||||||
NULL
|
|
||||||
)
|
|
||||||
ON CONFLICT (plan) DO NOTHING
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.execute("DELETE FROM plan_limits WHERE plan = 'starter'")
|
|
||||||
op.execute("UPDATE plan_limits SET plan = 'team' WHERE plan = 'enterprise'")
|
|
||||||
op.execute("UPDATE subscriptions SET plan = 'team' WHERE plan = 'enterprise'")
|
|
||||||
@@ -64,40 +64,6 @@ async def get_current_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user_optional(
|
|
||||||
request: Request,
|
|
||||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
|
||||||
) -> Optional[User]:
|
|
||||||
"""Best-effort current user for endpoints that work both anonymous and authed.
|
|
||||||
|
|
||||||
Returns None on missing/invalid/expired token instead of raising. Used by
|
|
||||||
surfaces like /config/public that anonymous clients can hit but where an
|
|
||||||
authenticated user gets a tailored response (e.g. INTERNAL_TESTER_EMAILS
|
|
||||||
allowlist override).
|
|
||||||
"""
|
|
||||||
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
|
|
||||||
if not auth_header or not auth_header.lower().startswith("bearer "):
|
|
||||||
return None
|
|
||||||
token = auth_header.split(None, 1)[1].strip()
|
|
||||||
if not token:
|
|
||||||
return None
|
|
||||||
|
|
||||||
payload = decode_token(token)
|
|
||||||
if payload is None or payload.get("type") != "access":
|
|
||||||
return None
|
|
||||||
|
|
||||||
user_id = payload.get("sub")
|
|
||||||
if user_id is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
user_uuid = UUID(user_id)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
result = await db.execute(select(User).where(User.id == user_uuid))
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_refresh_token_payload(
|
async def get_refresh_token_payload(
|
||||||
token: Annotated[str, Depends(oauth2_scheme)]
|
token: Annotated[str, Depends(oauth2_scheme)]
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
@@ -972,7 +972,7 @@ async def update_user_plan(
|
|||||||
current_user: Annotated[User, Depends(require_admin)],
|
current_user: Annotated[User, Depends(require_admin)],
|
||||||
):
|
):
|
||||||
"""Change a user's subscription plan (super admin only)."""
|
"""Change a user's subscription plan (super admin only)."""
|
||||||
if data.plan not in ("free", "pro", "starter", "enterprise"):
|
if data.plan not in ("free", "pro", "team"):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
|
||||||
user, subscription = await _get_user_subscription(user_id, db)
|
user, subscription = await _get_user_subscription(user_id, db)
|
||||||
old_plan = subscription.plan
|
old_plan = subscription.plan
|
||||||
@@ -991,7 +991,7 @@ async def update_account_plan(
|
|||||||
current_user: Annotated[User, Depends(require_admin)],
|
current_user: Annotated[User, Depends(require_admin)],
|
||||||
):
|
):
|
||||||
"""Change an account subscription plan (super admin only)."""
|
"""Change an account subscription plan (super admin only)."""
|
||||||
if data.plan not in ("free", "pro", "starter", "enterprise"):
|
if data.plan not in ("free", "pro", "team"):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
|
||||||
account, subscription = await _get_account_subscription(account_id, db)
|
account, subscription = await _get_account_subscription(account_id, db)
|
||||||
old_plan = subscription.plan
|
old_plan = subscription.plan
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async def get_dashboard_metrics(
|
|||||||
) or 0
|
) or 0
|
||||||
paid_accounts = await db.scalar(
|
paid_accounts = await db.scalar(
|
||||||
select(func.count()).select_from(Subscription).where(
|
select(func.count()).select_from(Subscription).where(
|
||||||
Subscription.plan.in_(["pro", "starter", "enterprise"])
|
Subscription.plan.in_(["pro", "team"])
|
||||||
)
|
)
|
||||||
) or 0
|
) or 0
|
||||||
total_trees = await db.scalar(
|
total_trees = await db.scalar(
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ async def register(
|
|||||||
# and so paid/trial-bearing codes still apply when supplied.
|
# and so paid/trial-bearing codes still apply when supplied.
|
||||||
if (
|
if (
|
||||||
settings.REQUIRE_INVITE_CODE
|
settings.REQUIRE_INVITE_CODE
|
||||||
and not settings.is_self_serve_active_for(user_data.email)
|
and not settings.SELF_SERVE_ENABLED
|
||||||
and not user_data.invite_code
|
and not user_data.invite_code
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -11,31 +11,22 @@ frontend codegen and other call sites if needed.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated, Optional
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from app.api.deps import get_current_user_optional
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.config import PublicConfigResponse
|
from app.schemas.config import PublicConfigResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/config", tags=["config"])
|
router = APIRouter(prefix="/config", tags=["config"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/public", response_model=PublicConfigResponse)
|
@router.get("/public", response_model=PublicConfigResponse)
|
||||||
async def get_public_config(
|
async def get_public_config() -> PublicConfigResponse:
|
||||||
current_user: Annotated[Optional[User], Depends(get_current_user_optional)],
|
|
||||||
) -> PublicConfigResponse:
|
|
||||||
"""Return public-safe runtime config.
|
"""Return public-safe runtime config.
|
||||||
|
|
||||||
`oauth_providers` reflects which OAuth client IDs are configured server
|
`oauth_providers` reflects which OAuth client IDs are configured server
|
||||||
side; the frontend uses it to render only buttons that will actually
|
side; the frontend uses it to render only buttons that will actually
|
||||||
succeed. `self_serve_enabled` is the master switch for the new public
|
succeed. `self_serve_enabled` is the master switch for the new public
|
||||||
self-serve signup flow; an authenticated caller whose email is on the
|
self-serve signup flow.
|
||||||
INTERNAL_TESTER_EMAILS allowlist sees `True` even when the global flag
|
|
||||||
is off, so internal validation in prod test mode can exercise the full
|
|
||||||
surface before the public flip.
|
|
||||||
"""
|
"""
|
||||||
providers: list[str] = []
|
providers: list[str] = []
|
||||||
if settings.GOOGLE_CLIENT_ID:
|
if settings.GOOGLE_CLIENT_ID:
|
||||||
@@ -43,8 +34,7 @@ async def get_public_config(
|
|||||||
if settings.MS_CLIENT_ID:
|
if settings.MS_CLIENT_ID:
|
||||||
providers.append("microsoft")
|
providers.append("microsoft")
|
||||||
|
|
||||||
user_email = current_user.email if current_user else None
|
|
||||||
return PublicConfigResponse(
|
return PublicConfigResponse(
|
||||||
self_serve_enabled=settings.is_self_serve_active_for(user_email),
|
self_serve_enabled=settings.SELF_SERVE_ENABLED,
|
||||||
oauth_providers=providers,
|
oauth_providers=providers,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -97,40 +97,6 @@ class Settings(BaseSettings):
|
|||||||
STRIPE_WEBHOOK_SECRET: Optional[str] = None
|
STRIPE_WEBHOOK_SECRET: Optional[str] = None
|
||||||
SELF_SERVE_ENABLED: bool = False
|
SELF_SERVE_ENABLED: bool = False
|
||||||
|
|
||||||
# Internal tester allowlist for soft cutover. Comma-separated emails;
|
|
||||||
# when SELF_SERVE_ENABLED is False, listed users still see the self-serve
|
|
||||||
# surfaces (pricing page, invite-code-optional registration, etc.) so the
|
|
||||||
# full flow can be exercised in prod test mode before public flip.
|
|
||||||
INTERNAL_TESTER_EMAILS: list[str] = []
|
|
||||||
|
|
||||||
@field_validator("INTERNAL_TESTER_EMAILS", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def split_internal_tester_emails(cls, v) -> list[str]:
|
|
||||||
"""Parse a comma-separated string into a normalized lowercase list."""
|
|
||||||
if v is None or v == "":
|
|
||||||
return []
|
|
||||||
if isinstance(v, list):
|
|
||||||
return [e.strip().lower() for e in v if e and e.strip()]
|
|
||||||
if isinstance(v, str):
|
|
||||||
return [e.strip().lower() for e in v.split(",") if e.strip()]
|
|
||||||
return []
|
|
||||||
|
|
||||||
def is_internal_tester(self, email: Optional[str]) -> bool:
|
|
||||||
"""Case-insensitive allowlist check. None/empty email is never a tester."""
|
|
||||||
if not email:
|
|
||||||
return False
|
|
||||||
return email.lower() in self.INTERNAL_TESTER_EMAILS
|
|
||||||
|
|
||||||
def is_self_serve_active_for(self, email: Optional[str]) -> bool:
|
|
||||||
"""True if self-serve surfaces should render for this user.
|
|
||||||
|
|
||||||
Either the global flag is on, or the user is on the internal-tester
|
|
||||||
allowlist. Anonymous calls (email is None) only see the global flag.
|
|
||||||
"""
|
|
||||||
if self.SELF_SERVE_ENABLED:
|
|
||||||
return True
|
|
||||||
return self.is_internal_tester(email)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def stripe_enabled(self) -> bool:
|
def stripe_enabled(self) -> bool:
|
||||||
"""Check if Stripe is configured."""
|
"""Check if Stripe is configured."""
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ class Subscription(Base):
|
|||||||
@property
|
@property
|
||||||
def is_paid(self) -> bool:
|
def is_paid(self) -> bool:
|
||||||
# Excludes complimentary and trialing so MRR/paid-customer metrics aren't inflated.
|
# Excludes complimentary and trialing so MRR/paid-customer metrics aren't inflated.
|
||||||
return self.plan in ("pro", "starter", "enterprise") and self.status not in ("complimentary", "trialing")
|
return self.plan in ("pro", "team") and self.status not in ("complimentary", "trialing")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_pro_entitlement(self) -> bool:
|
def has_pro_entitlement(self) -> bool:
|
||||||
"""True if the account can access Pro features right now."""
|
"""True if the account can access Pro features right now."""
|
||||||
if self.plan in ("pro", "starter", "enterprise"):
|
if self.plan in ("pro", "team"):
|
||||||
if self.status in ("active", "complimentary"):
|
if self.status in ("active", "complimentary"):
|
||||||
return True
|
return True
|
||||||
if self.status == "trialing" and self.current_period_end is not None:
|
if self.status == "trialing" and self.current_period_end is not None:
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class AdminAccountDetailResponse(AdminAccountListItem):
|
|||||||
|
|
||||||
class AdminAccountCreate(BaseModel):
|
class AdminAccountCreate(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
plan: Literal["free", "pro", "starter", "enterprise"] = "free"
|
plan: Literal["free", "pro", "team"] = "free"
|
||||||
owner_email: Optional[EmailStr] = Field(None, description="Email of an existing user to set as owner")
|
owner_email: Optional[EmailStr] = Field(None, description="Email of an existing user to set as owner")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
|
|
||||||
class CheckoutSessionCreate(BaseModel):
|
class CheckoutSessionCreate(BaseModel):
|
||||||
plan: Literal["pro", "starter", "enterprise"]
|
plan: Literal["pro", "starter", "team", "enterprise"]
|
||||||
seats: int
|
seats: int
|
||||||
billing_interval: Literal["monthly", "annual"] = "monthly"
|
billing_interval: Literal["monthly", "annual"] = "monthly"
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class InviteCodeCreate(BaseModel):
|
|||||||
expires_at: Optional[datetime] = Field(None, description="Optional expiration time")
|
expires_at: Optional[datetime] = Field(None, description="Optional expiration time")
|
||||||
note: Optional[str] = Field(None, max_length=255, description="Note about who this code is for")
|
note: Optional[str] = Field(None, max_length=255, description="Note about who this code is for")
|
||||||
email: Optional[EmailStr] = Field(None, description="Recipient email for invite delivery")
|
email: Optional[EmailStr] = Field(None, description="Recipient email for invite delivery")
|
||||||
assigned_plan: Literal["free", "pro", "starter", "enterprise"] = Field("free", description="Plan to assign on registration")
|
assigned_plan: Literal["free", "pro", "team"] = Field("free", description="Plan to assign on registration")
|
||||||
trial_duration_days: Optional[int] = Field(None, ge=1, le=90, description="Trial duration in days (1-90)")
|
trial_duration_days: Optional[int] = Field(None, ge=1, le=90, description="Trial duration in days (1-90)")
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class SubscriptionDetails(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SubscriptionPlanUpdate(BaseModel):
|
class SubscriptionPlanUpdate(BaseModel):
|
||||||
plan: str # free, pro, starter, enterprise
|
plan: str # free, pro, team
|
||||||
|
|
||||||
model_config = {"json_schema_extra": {"examples": [{"plan": "pro"}]}}
|
model_config = {"json_schema_extra": {"examples": [{"plan": "pro"}]}}
|
||||||
|
|
||||||
|
|||||||
@@ -97,18 +97,7 @@ async def main() -> None:
|
|||||||
)
|
)
|
||||||
row = result.first()
|
row = result.first()
|
||||||
if row:
|
if row:
|
||||||
# Backfill email_verified_at for existing rows so older test
|
print(f" [SKIP] {cfg['email']} already exists")
|
||||||
# users created before this script set the field still bypass
|
|
||||||
# the 7-day verification grace.
|
|
||||||
await conn.execute(
|
|
||||||
text("""
|
|
||||||
UPDATE users
|
|
||||||
SET email_verified_at = COALESCE(email_verified_at, :now)
|
|
||||||
WHERE email = :email
|
|
||||||
"""),
|
|
||||||
{"email": cfg["email"], "now": now},
|
|
||||||
)
|
|
||||||
print(f" [SKIP] {cfg['email']} already exists (email_verified_at backfilled if null)")
|
|
||||||
if cfg["key"] == "team_admin":
|
if cfg["key"] == "team_admin":
|
||||||
team_account_id = row.account_id
|
team_account_id = row.account_id
|
||||||
continue
|
continue
|
||||||
@@ -141,17 +130,12 @@ async def main() -> None:
|
|||||||
|
|
||||||
# ---- Create User ----
|
# ---- Create User ----
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
# email_verified_at is stamped at seed time so test users bypass the
|
|
||||||
# 7-day verification grace immediately. Without this, fixtures hit
|
|
||||||
# require_verified_email_after_grace once their created_at ages past
|
|
||||||
# 7 days and get walled out of protected routes.
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text("""
|
text("""
|
||||||
INSERT INTO users (id, email, password_hash, name, role, is_super_admin,
|
INSERT INTO users (id, email, password_hash, name, role, is_super_admin,
|
||||||
is_team_admin, is_active, account_id, account_role,
|
is_team_admin, is_active, account_id, account_role, created_at)
|
||||||
created_at, email_verified_at)
|
|
||||||
VALUES (:id, :email, :pw, :name, 'engineer', :is_sa, :is_ta, true,
|
VALUES (:id, :email, :pw, :name, 'engineer', :is_sa, :is_ta, true,
|
||||||
:account_id, :account_role, :now, :now)
|
:account_id, :account_role, :now)
|
||||||
"""),
|
"""),
|
||||||
{
|
{
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Sync plan_billing rows from Stripe products and prices.
|
|
||||||
|
|
||||||
Reads the active Stripe environment (test or live, determined by
|
|
||||||
STRIPE_SECRET_KEY in env), looks up the canonical ResolutionFlow products
|
|
||||||
by exact name match, picks the active monthly recurring price for tiers
|
|
||||||
that have one, and upserts plan_billing rows.
|
|
||||||
|
|
||||||
Idempotent. Safe to re-run after price changes, after live cutover, or
|
|
||||||
after rotating Stripe keys.
|
|
||||||
|
|
||||||
Tier mapping (name in Stripe -> plan slug in plan_limits):
|
|
||||||
ResolutionFlow Starter -> starter (monthly price required)
|
|
||||||
ResolutionFlow Pro -> pro (monthly price required)
|
|
||||||
ResolutionFlow Enterprise -> enterprise (no price, sales-led)
|
|
||||||
|
|
||||||
Annual prices are intentionally not supported in this iteration. The
|
|
||||||
plan_billing schema allows annual fields (stripe_annual_price_id,
|
|
||||||
annual_price_cents); this script leaves them NULL.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
docker exec -w /app resolutionflow_backend python -m scripts.sync_stripe_plan_ids
|
|
||||||
docker exec -w /app resolutionflow_backend python -m scripts.sync_stripe_plan_ids --dry-run
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import stripe
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.database import async_session_maker
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("sync_stripe_plan_ids")
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s %(levelname)s %(message)s",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
PLAN_NAME_TO_SLUG = {
|
|
||||||
"ResolutionFlow Starter": "starter",
|
|
||||||
"ResolutionFlow Pro": "pro",
|
|
||||||
"ResolutionFlow Enterprise": "enterprise",
|
|
||||||
}
|
|
||||||
|
|
||||||
PLANS_REQUIRING_PRICE = {"starter", "pro"}
|
|
||||||
|
|
||||||
PLAN_DEFAULTS = {
|
|
||||||
"starter": {"sort_order": 10, "is_public": True},
|
|
||||||
"pro": {"sort_order": 20, "is_public": True},
|
|
||||||
"enterprise": {"sort_order": 30, "is_public": True},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def find_product_by_name(target: str) -> Optional[stripe.Product]:
|
|
||||||
"""Page through active products and return the first exact name match."""
|
|
||||||
for product in stripe.Product.list(active=True, limit=100).auto_paging_iter():
|
|
||||||
if product.name == target:
|
|
||||||
return product
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def find_active_monthly_price(product_id: str) -> Optional[stripe.Price]:
|
|
||||||
"""Return the active recurring monthly price for a product, or None."""
|
|
||||||
candidates = [
|
|
||||||
p
|
|
||||||
for p in stripe.Price.list(product=product_id, active=True, limit=100).auto_paging_iter()
|
|
||||||
if p.type == "recurring"
|
|
||||||
and p.recurring is not None
|
|
||||||
and p.recurring.get("interval") == "month"
|
|
||||||
and p.recurring.get("interval_count", 1) == 1
|
|
||||||
]
|
|
||||||
if not candidates:
|
|
||||||
return None
|
|
||||||
if len(candidates) > 1:
|
|
||||||
logger.warning(
|
|
||||||
"Product %s has %d active monthly recurring prices; picking %s. "
|
|
||||||
"Archive the others to silence this warning.",
|
|
||||||
product_id, len(candidates), candidates[0].id,
|
|
||||||
)
|
|
||||||
return candidates[0]
|
|
||||||
|
|
||||||
|
|
||||||
async def upsert_plan_billing(
|
|
||||||
plan: str,
|
|
||||||
display_name: str,
|
|
||||||
description: Optional[str],
|
|
||||||
monthly_price_cents: Optional[int],
|
|
||||||
stripe_product_id: Optional[str],
|
|
||||||
stripe_monthly_price_id: Optional[str],
|
|
||||||
sort_order: int,
|
|
||||||
is_public: bool,
|
|
||||||
dry_run: bool,
|
|
||||||
) -> None:
|
|
||||||
"""Upsert one plan_billing row. Annual fields stay NULL."""
|
|
||||||
if dry_run:
|
|
||||||
logger.info(
|
|
||||||
"[dry-run] would upsert plan=%s display=%s monthly_cents=%s "
|
|
||||||
"product=%s monthly_price=%s",
|
|
||||||
plan, display_name, monthly_price_cents,
|
|
||||||
stripe_product_id, stripe_monthly_price_id,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
sql = text("""
|
|
||||||
INSERT INTO plan_billing (
|
|
||||||
plan, display_name, description,
|
|
||||||
monthly_price_cents, annual_price_cents,
|
|
||||||
stripe_product_id, stripe_monthly_price_id, stripe_annual_price_id,
|
|
||||||
is_public, is_archived, sort_order
|
|
||||||
) VALUES (
|
|
||||||
:plan, :display_name, :description,
|
|
||||||
:monthly_price_cents, NULL,
|
|
||||||
:stripe_product_id, :stripe_monthly_price_id, NULL,
|
|
||||||
:is_public, FALSE, :sort_order
|
|
||||||
)
|
|
||||||
ON CONFLICT (plan) DO UPDATE SET
|
|
||||||
display_name = EXCLUDED.display_name,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
monthly_price_cents = EXCLUDED.monthly_price_cents,
|
|
||||||
stripe_product_id = EXCLUDED.stripe_product_id,
|
|
||||||
stripe_monthly_price_id = EXCLUDED.stripe_monthly_price_id,
|
|
||||||
is_public = EXCLUDED.is_public,
|
|
||||||
sort_order = EXCLUDED.sort_order,
|
|
||||||
updated_at = NOW()
|
|
||||||
""")
|
|
||||||
async with async_session_maker() as session:
|
|
||||||
await session.execute(sql, {
|
|
||||||
"plan": plan,
|
|
||||||
"display_name": display_name,
|
|
||||||
"description": description,
|
|
||||||
"monthly_price_cents": monthly_price_cents,
|
|
||||||
"stripe_product_id": stripe_product_id,
|
|
||||||
"stripe_monthly_price_id": stripe_monthly_price_id,
|
|
||||||
"is_public": is_public,
|
|
||||||
"sort_order": sort_order,
|
|
||||||
})
|
|
||||||
await session.commit()
|
|
||||||
logger.info("upserted plan_billing for plan=%s", plan)
|
|
||||||
|
|
||||||
|
|
||||||
async def main(dry_run: bool) -> int:
|
|
||||||
if not settings.STRIPE_SECRET_KEY:
|
|
||||||
logger.error("STRIPE_SECRET_KEY is not set. Refusing to run.")
|
|
||||||
return 2
|
|
||||||
|
|
||||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
||||||
mode = "live" if settings.STRIPE_SECRET_KEY.startswith("sk_live_") else "test"
|
|
||||||
logger.info("connected to Stripe in %s mode", mode)
|
|
||||||
|
|
||||||
errors: list[str] = []
|
|
||||||
|
|
||||||
for product_name, plan in PLAN_NAME_TO_SLUG.items():
|
|
||||||
defaults = PLAN_DEFAULTS[plan]
|
|
||||||
product = find_product_by_name(product_name)
|
|
||||||
if product is None:
|
|
||||||
errors.append(f"Stripe product not found: {product_name!r}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
price = None
|
|
||||||
if plan in PLANS_REQUIRING_PRICE:
|
|
||||||
price = find_active_monthly_price(product.id)
|
|
||||||
if price is None:
|
|
||||||
errors.append(
|
|
||||||
f"No active monthly recurring price for {product_name!r} "
|
|
||||||
f"(product {product.id})"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
await upsert_plan_billing(
|
|
||||||
plan=plan,
|
|
||||||
display_name=product.name,
|
|
||||||
description=product.description,
|
|
||||||
monthly_price_cents=price.unit_amount if price else None,
|
|
||||||
stripe_product_id=product.id,
|
|
||||||
stripe_monthly_price_id=price.id if price else None,
|
|
||||||
sort_order=defaults["sort_order"],
|
|
||||||
is_public=defaults["is_public"],
|
|
||||||
dry_run=dry_run,
|
|
||||||
)
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
for e in errors:
|
|
||||||
logger.error(e)
|
|
||||||
return 1
|
|
||||||
logger.info("done")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("--dry-run", action="store_true", help="Log actions without writing.")
|
|
||||||
args = parser.parse_args()
|
|
||||||
sys.exit(asyncio.run(main(dry_run=args.dry_run)))
|
|
||||||
@@ -172,9 +172,8 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
INSERT INTO plan_limits (plan, max_trees, max_sessions_per_month, max_users, custom_branding, priority_support, export_formats)
|
INSERT INTO plan_limits (plan, max_trees, max_sessions_per_month, max_users, custom_branding, priority_support, export_formats)
|
||||||
VALUES
|
VALUES
|
||||||
('free', 3, 20, 1, false, false, '["markdown", "text"]'),
|
('free', 3, 20, 1, false, false, '["markdown", "text"]'),
|
||||||
('starter', 10, 75, 1, false, false, '["markdown", "text", "html"]'),
|
|
||||||
('pro', 25, 200, 5, true, false, '["markdown", "text", "html"]'),
|
('pro', 25, 200, 5, true, false, '["markdown", "text", "html"]'),
|
||||||
('enterprise', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]')
|
('team', NULL, NULL, NULL, true, true, '["markdown", "text", "html"]')
|
||||||
"""))
|
"""))
|
||||||
|
|
||||||
# Seed the platform/system account (PLATFORM_ACCOUNT_ID) needed by
|
# Seed the platform/system account (PLATFORM_ACCOUNT_ID) needed by
|
||||||
|
|||||||
@@ -122,9 +122,9 @@ class TestAdminPlanLimits:
|
|||||||
):
|
):
|
||||||
"""PUT /admin/plan-limits upserts a plan_billing row when billing
|
"""PUT /admin/plan-limits upserts a plan_billing row when billing
|
||||||
fields are included in the body."""
|
fields are included in the body."""
|
||||||
# Ensure no plan_billing row exists for "enterprise" yet.
|
# Ensure no plan_billing row exists for "team" yet.
|
||||||
existing = (await test_db.execute(
|
existing = (await test_db.execute(
|
||||||
select(PlanBilling).where(PlanBilling.plan == "enterprise")
|
select(PlanBilling).where(PlanBilling.plan == "team")
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
await test_db.delete(existing)
|
await test_db.delete(existing)
|
||||||
@@ -133,7 +133,7 @@ class TestAdminPlanLimits:
|
|||||||
response = await client.put(
|
response = await client.put(
|
||||||
"/api/v1/admin/plan-limits",
|
"/api/v1/admin/plan-limits",
|
||||||
json={
|
json={
|
||||||
"plan": "enterprise",
|
"plan": "team",
|
||||||
"max_trees": None,
|
"max_trees": None,
|
||||||
"max_sessions_per_month": None,
|
"max_sessions_per_month": None,
|
||||||
"max_users": None,
|
"max_users": None,
|
||||||
@@ -163,7 +163,7 @@ class TestAdminPlanLimits:
|
|||||||
# Confirm the row was actually persisted.
|
# Confirm the row was actually persisted.
|
||||||
await test_db.commit() # ensure session sees other-session writes
|
await test_db.commit() # ensure session sees other-session writes
|
||||||
pb = (await test_db.execute(
|
pb = (await test_db.execute(
|
||||||
select(PlanBilling).where(PlanBilling.plan == "enterprise")
|
select(PlanBilling).where(PlanBilling.plan == "team")
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
assert pb is not None
|
assert pb is not None
|
||||||
assert pb.display_name == "Team"
|
assert pb.display_name == "Team"
|
||||||
@@ -179,17 +179,17 @@ class TestAdminPlanLimits:
|
|||||||
plan_billing row when the caller passes explicit nulls. The set of
|
plan_billing row when the caller passes explicit nulls. The set of
|
||||||
guarded fields is {display_name, is_public, is_archived, sort_order}.
|
guarded fields is {display_name, is_public, is_archived, sort_order}.
|
||||||
"""
|
"""
|
||||||
# Seed a plan_billing row for "enterprise" with non-default values for every
|
# Seed a plan_billing row for "team" with non-default values for every
|
||||||
# NOT NULL field so we can detect any clobbering.
|
# NOT NULL field so we can detect any clobbering.
|
||||||
existing = (await test_db.execute(
|
existing = (await test_db.execute(
|
||||||
select(PlanBilling).where(PlanBilling.plan == "enterprise")
|
select(PlanBilling).where(PlanBilling.plan == "team")
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
await test_db.delete(existing)
|
await test_db.delete(existing)
|
||||||
await test_db.commit()
|
await test_db.commit()
|
||||||
|
|
||||||
seeded = PlanBilling(
|
seeded = PlanBilling(
|
||||||
plan="enterprise",
|
plan="team",
|
||||||
display_name="Team Seeded",
|
display_name="Team Seeded",
|
||||||
is_public=False,
|
is_public=False,
|
||||||
is_archived=True,
|
is_archived=True,
|
||||||
@@ -201,7 +201,7 @@ class TestAdminPlanLimits:
|
|||||||
response = await client.put(
|
response = await client.put(
|
||||||
"/api/v1/admin/plan-limits",
|
"/api/v1/admin/plan-limits",
|
||||||
json={
|
json={
|
||||||
"plan": "enterprise",
|
"plan": "team",
|
||||||
"max_trees": None,
|
"max_trees": None,
|
||||||
"max_sessions_per_month": None,
|
"max_sessions_per_month": None,
|
||||||
"max_users": None,
|
"max_users": None,
|
||||||
@@ -221,7 +221,7 @@ class TestAdminPlanLimits:
|
|||||||
# Confirm the seeded NOT NULL values were preserved.
|
# Confirm the seeded NOT NULL values were preserved.
|
||||||
await test_db.commit() # ensure session sees writes from the request
|
await test_db.commit() # ensure session sees writes from the request
|
||||||
pb = (await test_db.execute(
|
pb = (await test_db.execute(
|
||||||
select(PlanBilling).where(PlanBilling.plan == "enterprise")
|
select(PlanBilling).where(PlanBilling.plan == "team")
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
assert pb is not None
|
assert pb is not None
|
||||||
assert pb.display_name == "Team Seeded"
|
assert pb.display_name == "Team Seeded"
|
||||||
|
|||||||
@@ -49,58 +49,6 @@ class TestConfigPublic:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["oauth_providers"] == ["microsoft"]
|
assert response.json()["oauth_providers"] == ["microsoft"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_config_public_returns_true_for_internal_tester(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
auth_headers: dict,
|
|
||||||
test_user: dict,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
):
|
|
||||||
"""Authenticated user whose email is on INTERNAL_TESTER_EMAILS sees
|
|
||||||
self_serve_enabled=True even when the global flag is off."""
|
|
||||||
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
|
||||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
|
|
||||||
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
|
|
||||||
monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", [test_user["email"].lower()])
|
|
||||||
|
|
||||||
response = await client.get("/api/v1/config/public", headers=auth_headers)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["self_serve_enabled"] is True
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_config_public_returns_false_for_non_tester_when_global_off(
|
|
||||||
self,
|
|
||||||
client: AsyncClient,
|
|
||||||
auth_headers: dict,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
):
|
|
||||||
"""Authenticated user NOT on the allowlist sees the global flag —
|
|
||||||
prevents accidental opt-in via stale credentials or empty allowlist."""
|
|
||||||
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
|
||||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
|
|
||||||
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
|
|
||||||
monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", ["someone-else@example.com"])
|
|
||||||
|
|
||||||
response = await client.get("/api/v1/config/public", headers=auth_headers)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["self_serve_enabled"] is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_config_public_anonymous_ignores_allowlist(
|
|
||||||
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
||||||
):
|
|
||||||
"""Anonymous callers always see the global flag — the allowlist is
|
|
||||||
keyed on authenticated identity, not request content."""
|
|
||||||
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
|
||||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
|
|
||||||
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
|
|
||||||
monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", ["anon-tester@example.com"])
|
|
||||||
|
|
||||||
response = await client.get("/api/v1/config/public")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["self_serve_enabled"] is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestRegisterInviteCodeGate:
|
class TestRegisterInviteCodeGate:
|
||||||
"""Regression + new-behavior tests for /auth/register vs SELF_SERVE_ENABLED."""
|
"""Regression + new-behavior tests for /auth/register vs SELF_SERVE_ENABLED."""
|
||||||
@@ -150,55 +98,3 @@ class TestRegisterInviteCodeGate:
|
|||||||
assert body["email"] == "self-serve@example.com"
|
assert body["email"] == "self-serve@example.com"
|
||||||
assert body["account_role"] == "owner"
|
assert body["account_role"] == "owner"
|
||||||
assert "account_id" in body
|
assert "account_id" in body
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_register_invite_code_optional_for_internal_tester(
|
|
||||||
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
||||||
):
|
|
||||||
"""SELF_SERVE_ENABLED is False but the registering email is on
|
|
||||||
INTERNAL_TESTER_EMAILS — registration should succeed without an
|
|
||||||
invite code, matching the per-email soft-cutover behavior."""
|
|
||||||
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
|
|
||||||
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
settings, "INTERNAL_TESTER_EMAILS", ["tester@example.com"]
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/auth/register",
|
|
||||||
json={
|
|
||||||
"email": "tester@example.com",
|
|
||||||
"password": "SecurePass123!",
|
|
||||||
"name": "Internal Tester",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201, response.text
|
|
||||||
body = response.json()
|
|
||||||
assert body["email"] == "tester@example.com"
|
|
||||||
assert body["account_role"] == "owner"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_register_blocked_for_non_tester_when_self_serve_disabled(
|
|
||||||
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
||||||
):
|
|
||||||
"""Registering with an email NOT on the allowlist still 400s when
|
|
||||||
self-serve is off and no invite code is provided. Prevents the
|
|
||||||
allowlist from leaking to public users."""
|
|
||||||
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
|
|
||||||
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
settings, "INTERNAL_TESTER_EMAILS", ["other@example.com"]
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await client.post(
|
|
||||||
"/api/v1/auth/register",
|
|
||||||
json={
|
|
||||||
"email": "outsider@example.com",
|
|
||||||
"password": "SecurePass123!",
|
|
||||||
"name": "Outsider",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert "invite code is required" in response.json()["detail"].lower()
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class TestInviteCodeCreation:
|
|||||||
):
|
):
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/invites",
|
"/api/v1/invites",
|
||||||
json={"assigned_plan": "enterprise", "email": "beta@example.com"},
|
json={"assigned_plan": "team", "email": "beta@example.com"},
|
||||||
headers=admin_auth_headers,
|
headers=admin_auth_headers,
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
@@ -149,7 +149,7 @@ class TestRegistrationWithInvitePlan:
|
|||||||
# Create team invite without trial
|
# Create team invite without trial
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/v1/invites",
|
"/api/v1/invites",
|
||||||
json={"assigned_plan": "enterprise"},
|
json={"assigned_plan": "team"},
|
||||||
headers=admin_auth_headers,
|
headers=admin_auth_headers,
|
||||||
)
|
)
|
||||||
code = resp.json()["code"]
|
code = resp.json()["code"]
|
||||||
@@ -172,7 +172,7 @@ class TestRegistrationWithInvitePlan:
|
|||||||
sub = (await test_db.execute(
|
sub = (await test_db.execute(
|
||||||
select(Subscription).where(Subscription.account_id == user.account_id)
|
select(Subscription).where(Subscription.account_id == user.account_id)
|
||||||
)).scalar_one()
|
)).scalar_one()
|
||||||
assert sub.plan == "enterprise"
|
assert sub.plan == "team"
|
||||||
assert sub.status == "active"
|
assert sub.status == "active"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,7 @@ from app.models.plan_limits import PlanLimits
|
|||||||
|
|
||||||
|
|
||||||
async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None:
|
async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None:
|
||||||
"""Ensure a plan_limits row exists with the given max_users.
|
"""Ensure a plan_limits row exists for the given plan name."""
|
||||||
|
|
||||||
Upserts: conftest seeds the canonical plans (free/starter/pro/enterprise)
|
|
||||||
so this helper has to overwrite max_users when a test wants different
|
|
||||||
values for fixture-driven assertions.
|
|
||||||
"""
|
|
||||||
existing = await test_db.get(PlanLimits, plan)
|
existing = await test_db.get(PlanLimits, plan)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
test_db.add(
|
test_db.add(
|
||||||
@@ -33,9 +28,7 @@ async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None:
|
|||||||
export_formats=["markdown", "text"],
|
export_formats=["markdown", "text"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
await test_db.commit()
|
||||||
existing.max_users = max_users
|
|
||||||
await test_db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetPlansPublic:
|
class TestGetPlansPublic:
|
||||||
|
|||||||
@@ -40,16 +40,11 @@ services:
|
|||||||
- ALGORITHM=HS256
|
- ALGORITHM=HS256
|
||||||
- ACCESS_TOKEN_EXPIRE_MINUTES=15
|
- ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||||
- REFRESH_TOKEN_EXPIRE_DAYS=7
|
- REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
- REQUIRE_INVITE_CODE=false
|
- REQUIRE_INVITE_CODE=true
|
||||||
- FEEDBACK_EMAIL=feedback@resolutionflow.com
|
- FEEDBACK_EMAIL=feedback@resolutionflow.com
|
||||||
- AI_PROVIDER=anthropic
|
- AI_PROVIDER=anthropic
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
- GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY:-}
|
- GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY:-}
|
||||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
|
||||||
- STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-}
|
|
||||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
|
||||||
- SELF_SERVE_ENABLED=${SELF_SERVE_ENABLED:-false}
|
|
||||||
- INTERNAL_TESTER_EMAILS=${INTERNAL_TESTER_EMAILS:-}
|
|
||||||
- ENABLE_MCP_MICROSOFT_LEARN=true
|
- ENABLE_MCP_MICROSOFT_LEARN=true
|
||||||
- FRONTEND_URL=http://docker-01:5173
|
- FRONTEND_URL=http://docker-01:5173
|
||||||
- CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173","http://docker-01:5173","http://100.64.78.44:5173"]
|
- CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173","http://docker-01:5173","http://100.64.78.44:5173"]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function useSubscription() {
|
|||||||
const usage = subscription?.usage ?? null
|
const usage = subscription?.usage ?? null
|
||||||
const isActive = subscription?.subscription.status === 'active' || subscription?.subscription.status === 'trialing'
|
const isActive = subscription?.subscription.status === 'active' || subscription?.subscription.status === 'trialing'
|
||||||
|
|
||||||
const isPaidPlan = plan === 'pro' || plan === 'starter' || plan === 'enterprise'
|
const isPaidPlan = plan === 'pro' || plan === 'team'
|
||||||
|
|
||||||
const canUseFeature = (feature: 'custom_branding' | 'priority_support'): boolean => {
|
const canUseFeature = (feature: 'custom_branding' | 'priority_support'): boolean => {
|
||||||
if (!limits) return false
|
if (!limits) return false
|
||||||
|
|||||||
Reference in New Issue
Block a user