pénzügyi modul továbbfejlesztése (csomagkezelés)

This commit is contained in:
Roo
2026-07-29 09:46:10 +00:00
parent 6f28d3e70d
commit 75904cd2f8
50 changed files with 5851 additions and 312 deletions

View File

@@ -256,8 +256,15 @@ async def get_user_vehicles(
# Use a raw SQL expression for JSONB boolean ordering
order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")
if current_user.scope_id is None:
# Personal mode: show vehicles in organizations where user is a member
# P0 BUGFIX (2026-07-28): Staff users (ADMIN, SUPERADMIN, MODERATOR, etc.)
# should NOT enter corporate mode even if scope_id is set. They need to
# see vehicles through the personal/org membership path.
staff_roles = {'SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR'}
current_role = current_user.role.upper() if current_user.role else ''
is_staff_user = current_role in staff_roles
if current_user.scope_id is None or is_staff_user:
# Personal mode OR staff user: show vehicles in organizations where user is a member
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)

View File

@@ -1,9 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request, Header, Query
from fastapi import APIRouter, Depends, HTTPException, status, Request, Header, Query, Form
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from typing import Optional, Dict, Any, List
import logging
import uuid
from decimal import Decimal
from app.api.deps import get_db, get_current_user
from app.models.identity import User, Wallet, UserRole
@@ -13,6 +15,8 @@ from app.services.config_service import config
from app.services.payment_router import PaymentRouter
from app.services.stripe_adapter import stripe_adapter
from app.services.billing_engine import upgrade_subscription, get_user_balance
from app.services.subscription_activator import SubscriptionActivator
from app.core.config import settings
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -407,3 +411,302 @@ async def get_wallet_transactions(
except Exception as e:
logger.error(f"Tranzakció történet lekérdezési hiba: {e}")
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
# ──────────────────────────────────────────────────────────────────────────────
# Mock Payment Gateway — Checkout Page & Webhook Callback (Phase 2)
# ──────────────────────────────────────────────────────────────────────────────
#
# THOUGHT PROCESS:
# These two endpoints simulate a real payment gateway lifecycle:
#
# 1. GET /mock-payment/checkout/{intent_id}
# Displays a simple HTML page with "Pay Now" button.
# The user sees an amount and clicks to confirm payment.
# This is the URL returned as `checkout_url` by MockPaymentGateway
# in `simulate_redirect` mode.
#
# 2. POST /mock-payment/callback
# Simulates the webhook callback that a real payment gateway
# (like Stripe) would send after successful payment.
# Updates PaymentIntent PENDING → COMPLETED and activates
# the subscription using the metadata stored by FinancialManager.
#
# Together, these allow frontend testing of the full payment flow:
# purchase → redirect → pay → callback → activate subscription
# without requiring real Stripe integration.
# ──────────────────────────────────────────────────────────────────────────────
@router.get("/mock-payment/checkout/{intent_id}")
async def mock_checkout_page(
intent_id: str,
db: AsyncSession = Depends(get_db),
):
"""
Mock payment checkout page.
Displays a simple HTML page showing the payment amount and a "Pay Now"
button. When the user clicks the button, it POSTs to the mock callback
endpoint which finalizes the PaymentIntent and activates the subscription.
This simulates redirecting the user to a banking/payment portal.
Args:
intent_id: The mock gateway intent ID (stored as stripe_session_id).
db: Database session.
Returns:
HTMLResponse with a styled mock checkout page, or 404 if not found.
"""
logger.info(
"[MOCK_PAYMENT_CHECKOUT] Page requested for intent: %s",
intent_id,
)
# Look up the PaymentIntent by stripe_session_id (= gateway intent_id)
stmt = select(PaymentIntent).where(
PaymentIntent.stripe_session_id == intent_id,
PaymentIntent.status == PaymentIntentStatus.PENDING,
)
result = await db.execute(stmt)
payment_intent = result.scalar_one_or_none()
if not payment_intent:
logger.warning(
"[MOCK_PAYMENT_CHECKOUT] PaymentIntent not found or not PENDING: "
"intent_id=%s", intent_id,
)
return HTMLResponse(
content="<h1>Payment not found or already processed</h1>",
status_code=404,
)
amount = float(payment_intent.gross_amount)
currency = payment_intent.currency
# Determine the callback URL: POST back to the same origin
callback_url = f"/api/v1/billing/mock-payment/callback"
# Simple but styled HTML checkout page
html_content = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mock Payment Gateway</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; justify-content: center; align-items: center;
min-height: 100vh; padding: 20px;
}}
.card {{
background: white; border-radius: 16px; padding: 40px;
max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
}}
.card h1 {{ font-size: 24px; color: #1a1a2e; margin-bottom: 8px; }}
.card .subtitle {{ color: #666; font-size: 14px; margin-bottom: 24px; }}
.amount {{
font-size: 48px; font-weight: 700; color: #1a1a2e;
margin: 20px 0; padding: 20px 0;
border-top: 2px solid #f0f0f0; border-bottom: 2px solid #f0f0f0;
}}
.amount .currency {{ font-size: 24px; color: #666; }}
.details {{ text-align: left; margin: 20px 0; padding: 16px; background: #f8f9fa; border-radius: 8px; }}
.details dt {{ font-size: 12px; color: #999; text-transform: uppercase; letter-spacing: 0.5px; }}
.details dd {{ font-size: 14px; color: #333; margin-bottom: 8px; }}
.btn-pay {{
display: inline-block; padding: 14px 48px; margin-top: 16px;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white; border: none; border-radius: 8px;
font-size: 18px; font-weight: 600; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
width: 100%;
}}
.btn-pay:hover {{ transform: translateY(-2px); box-shadow: 0 8px 24px rgba(34,197,94,0.4); }}
.btn-pay:active {{ transform: translateY(0); }}
.footer {{ margin-top: 20px; font-size: 12px; color: #999; }}
.badge {{
display: inline-block; padding: 4px 12px; border-radius: 20px;
background: #fef3c7; color: #92400e; font-size: 12px; font-weight: 500;
margin-bottom: 16px;
}}
</style>
</head>
<body>
<div class="card">
<div class="badge">🧪 Mock Gateway — Development Mode</div>
<h1>Complete Your Payment</h1>
<p class="subtitle">This is a simulated payment page for testing</p>
<div class="amount">
{amount:.2f} <span class="currency">{currency}</span>
</div>
<dl class="details">
<dt>Intent ID</dt>
<dd>{intent_id[:20]}...</dd>
<dt>Description</dt>
<dd>Subscription Package Purchase</dd>
</dl>
<form action="{callback_url}" method="POST">
<input type="hidden" name="intent_id" value="{intent_id}">
<button type="submit" class="btn-pay">
✅ Pay {amount:.2f} {currency}
</button>
</form>
<p class="footer">
🔒 Connection simulated &bull; No real payment will be charged
</p>
</div>
</body>
</html>"""
logger.info(
"[MOCK_PAYMENT_CHECKOUT] Serving checkout page: intent=%s amount=%.2f %s",
intent_id, amount, currency,
)
return HTMLResponse(content=html_content)
@router.post("/mock-payment/callback")
async def mock_payment_callback(
intent_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
"""
Mock payment gateway callback (webhook simulation).
Simulates the webhook that a real payment gateway would send after
successful payment. This endpoint:
1. Looks up the PENDING PaymentIntent by gateway intent_id
2. Updates it to COMPLETED
3. Activates the subscription using stored metadata (tier_id, user_id, org_id)
4. Returns a redirect to the frontend success page (or JSON response)
Args:
intent_id: The mock gateway intent ID (Form field).
db: Database session.
Returns:
RedirectResponse to the frontend success URL, or JSONResponse if
the frontend URL is not configured.
"""
logger.info(
"[MOCK_PAYMENT_CALLBACK] Received callback for intent: %s",
intent_id,
)
# ── Step 1: Look up the PaymentIntent ─────────────────────────────────
stmt = select(PaymentIntent).where(
PaymentIntent.stripe_session_id == intent_id,
PaymentIntent.status == PaymentIntentStatus.PENDING,
)
result = await db.execute(stmt)
payment_intent = result.scalar_one_or_none()
if not payment_intent:
logger.warning(
"[MOCK_PAYMENT_CALLBACK] PaymentIntent not found or not PENDING: "
"intent_id=%s", intent_id,
)
return JSONResponse(
{"error": "PaymentIntent not found or not PENDING"},
status_code=404,
)
logger.info(
"[MOCK_PAYMENT_CALLBACK] PaymentIntent found: id=%d amount=%.2f %s",
payment_intent.id,
float(payment_intent.gross_amount),
payment_intent.currency,
)
# ── Step 2: Update PaymentIntent to COMPLETED ─────────────────────────
payment_intent.status = PaymentIntentStatus.COMPLETED
payment_intent.completed_at = datetime.utcnow()
# ── Step 3: Activate the subscription ─────────────────────────────────
# Read the purchase context from metadata (stored by FinancialManager)
meta = payment_intent.meta_data or {}
tier_id = meta.get("tier_id")
user_id = meta.get("user_id")
org_id = meta.get("org_id")
duration_days = meta.get("duration_days")
subscription_id = None
if tier_id and user_id:
activator = SubscriptionActivator()
try:
if org_id:
org_sub = await activator.activate_org_subscription(
db=db, org_id=int(org_id), tier_id=int(tier_id),
duration_days=int(duration_days) if duration_days else None,
)
subscription_id = org_sub.id
logger.info(
"[MOCK_PAYMENT_CALLBACK] Org subscription activated: "
"org_id=%s sub_id=%d", org_id, org_sub.id,
)
else:
user_sub = await activator.activate_user_subscription(
db=db, user_id=int(user_id), tier_id=int(tier_id),
duration_days=int(duration_days) if duration_days else None,
)
subscription_id = user_sub.id
logger.info(
"[MOCK_PAYMENT_CALLBACK] User subscription activated: "
"user_id=%s sub_id=%d", user_id, user_sub.id,
)
except Exception as e:
logger.error(
"[MOCK_PAYMENT_CALLBACK] Subscription activation failed: %s",
str(e),
)
# Don't fail the callback — the PaymentIntent is already COMPLETED
# The subscription can be activated manually or via retry logic
else:
logger.warning(
"[MOCK_PAYMENT_CALLBACK] Missing tier_id or user_id in metadata. "
"Subscription was NOT activated. metadata=%s", meta,
)
await db.commit()
logger.info(
"[MOCK_PAYMENT_CALLBACK] Payment completed successfully: "
"intent=%s, payment_intent_id=%d, amount=%.2f %s, "
"subscription_id=%s",
intent_id, payment_intent.id,
float(payment_intent.gross_amount), payment_intent.currency,
subscription_id,
)
# ── Step 4: Redirect to frontend ──────────────────────────────────────
# In production, redirect to the frontend success page.
# If FRONTEND_URL is not configured, return JSON.
frontend_url = getattr(settings, "FRONTEND_URL", None) or getattr(config, "FRONTEND_URL", None)
if frontend_url:
redirect_url = f"{frontend_url}/dashboard/subscription?payment=success"
logger.info(
"[MOCK_PAYMENT_CALLBACK] Redirecting to frontend: %s",
redirect_url,
)
return RedirectResponse(url=redirect_url, status_code=302)
# Fallback: return JSON response
return JSONResponse({
"success": True,
"payment_intent_id": payment_intent.id,
"transaction_id": str(payment_intent.transaction_id) if payment_intent.transaction_id else None,
"subscription_id": subscription_id,
"message": "Payment completed successfully",
})

View File

@@ -42,7 +42,15 @@ def get_financial_manager() -> FinancialManager:
"""
Dependency that provides a configured FinancialManager instance.
Currently uses MockPaymentGateway as default. To switch to Stripe:
Uses MockPaymentGateway in simulate_redirect mode as default.
In this mode, paid tiers (> 0 EUR) generate a checkout URL;
the frontend redirects the user to a mock payment page, and a
webhook callback (POST /billing/mock-payment/callback) finalizes
the payment and activates the subscription.
Free tiers ($0) bypass payment entirely and are activated immediately.
To switch to Stripe:
from app.services.stripe_adapter import StripeAdapter
return FinancialManager(payment_gateway=StripeAdapter())
@@ -50,7 +58,7 @@ def get_financial_manager() -> FinancialManager:
A FinancialManager instance with the configured payment gateway.
"""
return FinancialManager(
payment_gateway=MockPaymentGateway(mode="auto_approve"),
payment_gateway=MockPaymentGateway(mode="simulate_redirect"),
)

View File

@@ -139,28 +139,61 @@ async def get_public_subscriptions(
**Szabály:**
- Csak azokat a csomagokat adja vissza, ahol a `rules.lifecycle.is_public` = true
(JSONB mező alapján szűrve). Ha a mező hiányzik, alapértelmezés szerint publikus.
- Defense-in-Depth: A `rules.type` mező alapján szűrjük a csomagokat a felhasználó
aktuális kontextusa szerint. Ha a user aktív szervezete `individual` típusú (vagy
nincs aktív szervezet), csak `private`/`consumer` típusú csomagokat adunk vissza.
Ha a szervezet business típusú, csak `corporate`/`business` típusú csomagokat.
- Minden csomaghoz tartozik egy `resolved_pricing` mező, amely a felhasználó
országkódja alapján feloldott árazást tartalmazza.
- A frontend tovább szűrhet a `name` mező alapján (private_ vs corp_ előtag).
"""
# 1. Determine user's country code
country_code = await get_user_country_code(current_user, db)
# 2. Fetch all public tiers
# 2. Determine if user is in corporate context (defense-in-depth)
active_org_id = getattr(current_user, "active_organization_id", None)
is_corporate = False
if active_org_id:
org_type_stmt = select(Organization.org_type).where(
Organization.id == active_org_id,
Organization.is_deleted == False,
)
org_type_result = await db.execute(org_type_stmt)
org_type = org_type_result.scalar_one_or_none()
if org_type and org_type != "individual":
is_corporate = True
# 3. Fetch all public tiers (filter out non-public at SQL level)
stmt = (
select(SubscriptionTier)
.where(
# Only show tiers where lifecycle.is_public is NOT explicitly 'false'
# Using SQLAlchemy JSONB path query (same approach as admin_packages.py line 134)
~SubscriptionTier.rules["lifecycle"]["is_public"].as_string().in_(["false"])
)
.order_by(SubscriptionTier.id)
)
result = await db.execute(stmt)
tiers = result.scalars().all()
# 3. Build response with resolved pricing
# 4. Build response with resolved pricing and type filtering
response: List[PublicSubscriptionTierResponse] = []
for t in tiers:
rules = t.rules or {}
if not rules.get("lifecycle", {}).get("is_public", True):
continue
# Defense-in-Depth: Filter by target audience type
rules_type = rules.get("type", None)
if rules_type:
if is_corporate:
# In corporate context: only show corporate/business packages
if rules_type not in ("corporate", "business"):
continue
else:
# In private context: only show private/consumer packages
if rules_type in ("corporate", "business"):
continue
# Resolve pricing for this user's country
resolved = resolve_pricing(rules, country_code)
resolved_pricing = None
@@ -253,12 +286,57 @@ async def get_my_subscription(
user_tier = await SubscriptionService.get_user_tier(db, user_id)
feature_flags = await SubscriptionService.get_user_feature_flags(db, user_id)
# ── P0 PENDING DOWNGRADE FIELDS (Issue #429) ──
# Check if there's a pending downgrade on the current subscription
has_pending_downgrade = False
pending_tier_name = None
pending_effective_date = None
if subscription_data:
pending_sub = None
if source == "organization" and active_org_id:
pending_stmt = select(OrganizationSubscription).options(
selectinload(OrganizationSubscription.pending_tier)
).where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True,
OrganizationSubscription.pending_tier_id.is_not(None),
).order_by(OrganizationSubscription.id.desc()).limit(1)
pending_result = await db.execute(pending_stmt)
pending_sub = pending_result.scalar_one_or_none()
elif source == "user":
pending_stmt = select(UserSubscription).options(
selectinload(UserSubscription.pending_tier)
).where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True,
UserSubscription.pending_tier_id.is_not(None),
).order_by(UserSubscription.id.desc()).limit(1)
pending_result = await db.execute(pending_stmt)
pending_sub = pending_result.scalar_one_or_none()
if pending_sub and pending_sub.pending_tier:
has_pending_downgrade = True
pt = pending_sub.pending_tier
pending_tier_name = (
pt.rules.get("display_name", pt.name)
if pt.rules else pt.name
)
pending_effective_date = (
pending_sub.valid_until.isoformat()
if pending_sub.valid_until else None
)
return {
"subscription": subscription_data,
"tier": user_tier,
"features": feature_flags.get("features", {}),
"expires_at": feature_flags.get("expires_at"),
"source": source,
# ── P0 Pending downgrade fields ──
"has_pending_downgrade": has_pending_downgrade,
"pending_tier_name": pending_tier_name,
"pending_effective_date": pending_effective_date,
}

View File

@@ -42,6 +42,155 @@ class NetworkResponse(BaseModel):
level3: List[NetworkMemberL2L3]
async def _resolve_subscription_data(
db: AsyncSession,
user: User,
active_org_id: Optional[int] = None,
) -> dict:
"""
P0 BUGFIX (2026-07-28): Unified subscription resolution shared by /auth/me and /users/me.
Resolves the user's real subscription limits (max_vehicles, max_garages),
display_name, expiry, auto-renewal fields, and active add-ons from the
assigned subscription_tier JSONB rules.
Resolution order:
1. Org-level subscription (OrganizationSubscription) if active_org_id is set
2. User-level subscription (UserSubscription) fallback
3. Organization.base_asset_limit as ultimate fallback
Returns a dict with all subscription-related fields ready to merge into
the user response.
"""
from app.models.core_logic import SubscriptionTier, OrganizationSubscription, UserSubscription
from app.models.marketplace.organization import Organization
tier: Optional[SubscriptionTier] = None
subscription_record = None
# Step 1: Try org-level subscription first
if active_org_id is not None:
org_sub_stmt = (
select(OrganizationSubscription)
.where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True,
)
.order_by(OrganizationSubscription.valid_from.desc())
.limit(1)
)
subscription_record = (await db.execute(org_sub_stmt)).scalar_one_or_none()
if subscription_record and subscription_record.tier_id:
tier = await db.get(SubscriptionTier, subscription_record.tier_id)
# Step 2: Fall back to user-level subscription (runs even when active_org_id is set)
if subscription_record is None:
user_sub_stmt = (
select(UserSubscription)
.where(
UserSubscription.user_id == user.id,
UserSubscription.is_active == True,
)
.order_by(UserSubscription.valid_from.desc())
.limit(1)
)
subscription_record = (await db.execute(user_sub_stmt)).scalar_one_or_none()
if subscription_record and subscription_record.tier_id:
tier = await db.get(SubscriptionTier, subscription_record.tier_id)
# Step 3: Extract limits from the resolved tier or fall back
max_vehicles = 1
max_garages = 1
if tier and tier.rules:
allowances = tier.rules.get("allowances", {})
max_vehicles = int(allowances.get("max_vehicles", 1))
max_garages = int(allowances.get("max_garages", 1))
elif active_org_id is not None:
org_stmt = select(Organization).where(Organization.id == active_org_id)
org = (await db.execute(org_stmt)).scalar_one_or_none()
if org:
max_vehicles = org.base_asset_limit or 1
max_garages = 1
# Step 4: Extract metadata from the resolved subscription record
subscription_display_name: Optional[str] = None
subscription_valid_until: Optional[datetime] = None
subscription_tier_id: Optional[int] = None
subscription_valid_from: Optional[datetime] = None
subscription_auto_renew: bool = False
subscription_next_renewal_date: Optional[datetime] = None
subscription_wallet_auto_deduct: bool = False
if subscription_record is not None:
subscription_valid_until = subscription_record.valid_until
subscription_tier_id = subscription_record.tier_id
subscription_valid_from = subscription_record.valid_from
subscription_auto_renew = getattr(subscription_record, 'auto_renew', False)
subscription_next_renewal_date = getattr(subscription_record, 'next_renewal_date', None)
subscription_wallet_auto_deduct = getattr(subscription_record, 'wallet_auto_deduct', False)
# Resolve display_name from the resolved tier
if tier and tier.rules:
subscription_display_name = tier.rules.get("display_name", None)
# Step 5: Resolve active add-on subscriptions
active_addons: List[Dict[str, Any]] = []
if active_org_id is not None:
addon_stmt = (
select(OrganizationSubscription, SubscriptionTier)
.join(SubscriptionTier, OrganizationSubscription.tier_id == SubscriptionTier.id)
.where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True,
SubscriptionTier.type == 'addon',
)
.order_by(OrganizationSubscription.valid_from.desc())
)
addon_result = await db.execute(addon_stmt)
for sub, tier_row in addon_result:
active_addons.append({
"tier_id": tier_row.id,
"display_name": tier_row.rules.get("display_name", tier_row.name) if tier_row.rules else tier_row.name,
"valid_from": sub.valid_from.isoformat() if sub.valid_from else None,
"valid_until": sub.valid_until.isoformat() if sub.valid_until else None,
"is_active": sub.is_active,
})
else:
addon_stmt = (
select(UserSubscription, SubscriptionTier)
.join(SubscriptionTier, UserSubscription.tier_id == SubscriptionTier.id)
.where(
UserSubscription.user_id == user.id,
UserSubscription.is_active == True,
SubscriptionTier.type == 'addon',
)
.order_by(UserSubscription.valid_from.desc())
)
addon_result = await db.execute(addon_stmt)
for sub, tier_row in addon_result:
active_addons.append({
"tier_id": tier_row.id,
"display_name": tier_row.rules.get("display_name", tier_row.name) if tier_row.rules else tier_row.name,
"valid_from": sub.valid_from.isoformat() if sub.valid_from else None,
"valid_until": sub.valid_until.isoformat() if sub.valid_until else None,
"is_active": sub.is_active,
})
return {
"max_vehicles": max_vehicles,
"max_garages": max_garages,
"subscription_display_name": subscription_display_name,
"subscription_expires_at": subscription_valid_until.isoformat() if subscription_valid_until else None,
"subscription_tier_id": subscription_tier_id,
"subscription_valid_from": subscription_valid_from.isoformat() if subscription_valid_from else None,
"subscription_auto_renew": subscription_auto_renew,
"subscription_next_renewal_date": subscription_next_renewal_date.isoformat() if subscription_next_renewal_date else None,
"subscription_wallet_auto_deduct": subscription_wallet_auto_deduct,
"active_addons": active_addons,
"subscription_plan": user.subscription_plan,
}
async def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Optional[AsyncSession] = None) -> dict:
"""
Segédfüggvény a UserResponse dict előállításához.
@@ -113,10 +262,26 @@ async def _build_user_response(user: User, active_org_id: Optional[int] = None,
if db is not None:
pass
# ── P0: Resolve subscription limits ──
# Default fallback values
max_vehicles = 1
max_garages = 1
# ── P0: Resolve subscription limits via shared function ──
# P0 BUGFIX (2026-07-28): Previously hardcoded max_vehicles=1 / max_garages=1.
# Now uses _resolve_subscription_data() which queries the actual subscription_tier
# JSONB rules. This fixes both /auth/me and /users/me endpoints in one place.
if db is not None:
sub_data = await _resolve_subscription_data(db, user, active_org_id)
else:
sub_data = {
"max_vehicles": 1,
"max_garages": 1,
"subscription_display_name": None,
"subscription_expires_at": None,
"subscription_tier_id": None,
"subscription_valid_from": None,
"subscription_auto_renew": False,
"subscription_next_renewal_date": None,
"subscription_wallet_auto_deduct": False,
"active_addons": [],
"subscription_plan": user.subscription_plan,
}
return {
"id": user.id,
@@ -127,9 +292,19 @@ async def _build_user_response(user: User, active_org_id: Optional[int] = None,
"region_code": user.region_code,
"person_id": user.person_id,
"role": role_key,
"subscription_plan": user.subscription_plan,
"max_vehicles": max_vehicles,
"max_garages": max_garages,
"subscription_plan": sub_data["subscription_plan"],
"max_vehicles": sub_data["max_vehicles"],
"max_garages": sub_data["max_garages"],
"subscription_display_name": sub_data["subscription_display_name"],
"subscription_expires_at": sub_data["subscription_expires_at"],
"subscription_tier_id": sub_data["subscription_tier_id"],
"subscription_valid_from": sub_data["subscription_valid_from"],
"subscription_auto_renew": sub_data["subscription_auto_renew"],
"subscription_next_renewal_date": sub_data["subscription_next_renewal_date"],
"subscription_wallet_auto_deduct": sub_data["subscription_wallet_auto_deduct"],
"active_addons": sub_data["active_addons"],
"user_registration_date": user.created_at.isoformat() if user.created_at else None,
"created_at": user.created_at.isoformat() if user.created_at else None,
"scope_level": user.scope_level or "individual",
"scope_id": str(active_org_id) if active_org_id else None,
"ui_mode": user.ui_mode or "personal",
@@ -204,60 +379,21 @@ async def read_users_me(
is_last_admin = await AuthService.check_is_last_admin(db, current_user.id)
# ── P0: Resolve real subscription limits from the assigned tier ──
from app.models.core_logic import SubscriptionTier, OrganizationSubscription, UserSubscription
max_vehicles = 1
max_garages = 1
if active_org_id is not None:
# Try org-level subscription first
org_sub_stmt = (
select(SubscriptionTier)
.select_from(OrganizationSubscription)
.join(SubscriptionTier, OrganizationSubscription.tier_id == SubscriptionTier.id)
.where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True
)
.order_by(OrganizationSubscription.valid_from.desc())
.limit(1)
)
tier = (await db.execute(org_sub_stmt)).scalar_one_or_none()
if tier and tier.rules:
allowances = tier.rules.get("allowances", {})
max_vehicles = int(allowances.get("max_vehicles", 1))
max_garages = int(allowances.get("max_garages", 1))
else:
# Fallback: read base_asset_limit from Organization record
org_stmt = select(Organization).where(Organization.id == active_org_id)
org = (await db.execute(org_stmt)).scalar_one_or_none()
if org:
max_vehicles = org.base_asset_limit or 1
max_garages = 1 # No base_garage_limit column, default to 1
else:
# Personal mode: try user-level subscription
user_sub_stmt = (
select(SubscriptionTier)
.select_from(UserSubscription)
.join(SubscriptionTier, UserSubscription.tier_id == SubscriptionTier.id)
.where(
UserSubscription.user_id == current_user.id,
UserSubscription.is_active == True
)
.order_by(UserSubscription.valid_from.desc())
.limit(1)
)
tier = (await db.execute(user_sub_stmt)).scalar_one_or_none()
if tier and tier.rules:
allowances = tier.rules.get("allowances", {})
max_vehicles = int(allowances.get("max_vehicles", 1))
max_garages = int(allowances.get("max_garages", 1))
# P0 BUGFIX (2026-07-28): Uses the shared _resolve_subscription_data() function
# instead of duplicating the subscription resolution logic inline.
# This fixes both /auth/me and /users/me in one place.
#
# _build_user_response() already calls _resolve_subscription_data() internally,
# so _build_user_response now returns all subscription fields (max_vehicles,
# subscription_display_name, etc.) in the base response. We just need to
# override is_last_admin and org_capabilities post-build.
sub_data = await _resolve_subscription_data(db, current_user, active_org_id)
# ── RBAC Phase 3: Build base response ──
# P0 Phase 6: Pass db so system_capabilities gets populated from DB
response_data = await _build_user_response(current_user, active_org_id, db)
response_data["is_last_admin"] = is_last_admin
response_data["max_vehicles"] = max_vehicles
response_data["max_garages"] = max_garages
# All subscription fields are already in response_data from _build_user_response
# ── RBAC Phase 3: Resolve org_capabilities ──
# Get all organizations the user is a member of

View File

@@ -164,12 +164,60 @@ class OrganizationSubscription(Base):
# Példa: {"extra_vehicles": 1, "extra_garages": 1, "extra_credits": 100}
extra_allowances: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"), default=dict)
# ── P0 AUTO-RENEWAL FIELDS ──
auto_renew: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false"),
comment="Whether this subscription auto-renews at expiry"
)
provider_subscription_id: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True,
comment="External payment gateway subscription ID (e.g., Stripe sub_xxx)"
)
wallet_auto_deduct: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false"),
comment="Auto-deduct from wallet at renewal time"
)
renewal_failure_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=text("0"),
comment="Consecutive failed renewal attempts"
)
last_renewal_attempt: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="Timestamp of the most recent renewal attempt"
)
next_renewal_date: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="Pre-calculated next auto-renewal date (denormalized from valid_until for fast cron queries)"
)
# ── P0 PENDING DOWNGRADE FIELDS (Issue #429) ──
# When a user switches to a cheaper/free tier, the change takes effect
# only after the current, already-paid subscription period expires.
# pending_tier_id stores the target tier for the delayed activation.
pending_tier_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("system.subscription_tiers.id"), nullable=True,
default=None, server_default=text("NULL"),
comment="If set, this is a pending downgrade that activates after current period expires (FK → system.subscription_tiers.id)"
)
pending_activated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
default=None, server_default=text("NULL"),
comment="When the pending downgrade was requested (audit trail)"
)
# ── P0 FEATURE FLAG: relationship a SubscriptionTier-hez ──
tier: Mapped[Optional["SubscriptionTier"]] = relationship(
"SubscriptionTier",
foreign_keys=[tier_id],
lazy="selectin",
)
# ── P0 PENDING DOWNGRADE: kapcsolat a pending tier-hez ──
pending_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
"SubscriptionTier",
foreign_keys=[pending_tier_id],
lazy="selectin",
)
class UserSubscription(Base):
"""
@@ -193,12 +241,60 @@ class UserSubscription(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# ── P0 AUTO-RENEWAL FIELDS ──
auto_renew: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false"),
comment="Whether this subscription auto-renews at expiry"
)
provider_subscription_id: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True,
comment="External payment gateway subscription ID (e.g., Stripe sub_xxx)"
)
wallet_auto_deduct: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false"),
comment="Auto-deduct from wallet at renewal time"
)
renewal_failure_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default=text("0"),
comment="Consecutive failed renewal attempts"
)
last_renewal_attempt: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="Timestamp of the most recent renewal attempt"
)
next_renewal_date: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="Pre-calculated next auto-renewal date (denormalized from valid_until for fast cron queries)"
)
# ── P0 PENDING DOWNGRADE FIELDS (Issue #429) ──
# When a user switches to a cheaper/free tier, the change takes effect
# only after the current, already-paid subscription period expires.
# pending_tier_id stores the target tier for the delayed activation.
pending_tier_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("system.subscription_tiers.id"), nullable=True,
default=None, server_default=text("NULL"),
comment="If set, this is a pending downgrade that activates after current period expires (FK → system.subscription_tiers.id)"
)
pending_activated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
default=None, server_default=text("NULL"),
comment="When the pending downgrade was requested (audit trail)"
)
# ── P0 FEATURE FLAG: relationship a SubscriptionTier-hez ──
tier: Mapped[Optional["SubscriptionTier"]] = relationship(
"SubscriptionTier",
foreign_keys=[tier_id],
lazy="selectin",
)
# ── P0 PENDING DOWNGRADE: kapcsolat a pending tier-hez ──
pending_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
"SubscriptionTier",
foreign_keys=[pending_tier_id],
lazy="selectin",
)
class CreditTransaction(Base):

View File

@@ -50,7 +50,8 @@ class PurchaseResponse(BaseModel):
Response schema for a completed package purchase.
Contains the full receipt: payment details, subscription info,
and commission distribution results.
commission distribution results, and optionally a checkout_url
for paid tiers in simulate_redirect mode.
"""
success: bool = Field(..., description="Whether the purchase was successful")
payment_intent_id: Optional[int] = Field(None, description="The PaymentIntent ID")
@@ -63,6 +64,8 @@ class PurchaseResponse(BaseModel):
currency: str = Field("EUR", description="The payment currency")
gateway: str = Field("mock", description="The payment gateway used")
gateway_intent_id: Optional[str] = Field(None, description="The gateway's intent ID")
checkout_url: Optional[str] = Field(None, description="Checkout URL for payment redirect (simulate_redirect mode)")
payment_status: Optional[str] = Field(None, description="Payment status: PENDING, COMPLETED, etc.")
is_org_subscription: bool = Field(False, description="Whether this is an org-level subscription")
commission: Optional[CommissionInfo] = Field(None, description="Commission distribution results")
error: Optional[str] = Field(None, description="Error message if success=False")

View File

@@ -22,7 +22,7 @@ from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# ── Belső (embedded) modellek a rules JSONB struktúrához ──────────────────────
@@ -58,6 +58,16 @@ class DurationModel(BaseModel):
allow_stacking: bool = Field(default=True, description="Időhalmozás engedélyezése (stacking): ha True, a meglévő valid_until-hoz hozzáadódik")
class RenewalConfigModel(BaseModel):
"""Auto-renewal beállítások egy előfizetési csomaghoz (tier.rules.renewal JSONB)."""
enabled: bool = Field(default=False, description="Can this tier be auto-renewed?")
auto_renew_default: bool = Field(default=False, description="Default value for auto_renew when subscribing")
renewal_period_days: Optional[int] = Field(default=None, ge=1, description="Renewal period in days (None = use duration.days)")
retry_on_failure: bool = Field(default=True, description="Retry failed renewal attempts?")
max_retry_count: int = Field(default=3, ge=0, description="Max consecutive failed retries before disabling auto-renew")
grace_period_days: int = Field(default=7, ge=0, description="Grace period in days after expiry before suspension")
class AdPolicyModel(BaseModel):
"""Hirdetési szabályok a csomaghoz."""
show_ads: bool = Field(default=True, description="Megjelenjenek-e hirdetések a felhasználónak")
@@ -98,6 +108,7 @@ class SubscriptionRulesModel(BaseModel):
affiliate: Partnerprogram beállítások
lifecycle: Életciklus állapotok (opcionális, pl. is_public)
duration: Előfizetés időtartamának beállításai (duration_days, stacking)
renewal: Auto-renewal beállítások (enabled, auto_renew_default, retry_on_failure)
ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days)
marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)
"""
@@ -131,6 +142,10 @@ class SubscriptionRulesModel(BaseModel):
default=None,
description="Előfizetés időtartamának beállításai (duration_days, allow_stacking)"
)
renewal: Optional[RenewalConfigModel] = Field(
default=None,
description="Auto-renewal beállítások (enabled, auto_renew_default, retry_on_failure, max_retry_count, grace_period_days)"
)
ad_policy: Optional[AdPolicyModel] = Field(
default=None,
description="Hirdetési szabályok (show_ads, ad_free_grace_days, max_daily_impressions)"
@@ -149,6 +164,104 @@ class SubscriptionRulesModel(BaseModel):
raise ValueError("A 'type' mező csak 'private', 'corporate' vagy 'addon' lehet.")
return v.lower()
@model_validator(mode="before")
@classmethod
def handle_legacy_data(cls, data: Any) -> Any:
"""
P0 CRITICAL: Sanitize legacy/incomplete database rows before Pydantic validation.
Legacy rows may have:
- `pricing: {}` (empty dict) → convert to None
- `type: "base"` or missing → convert to None (will be treated as private)
- `lifecycle: {}` (empty dict) → convert to None
- missing `renewal` block → already None (no action needed)
- `affiliate: {}` (empty dict) → convert to None
- `marketing: {}` (empty dict) → convert to None
- `allowances: {}` (empty dict) → convert to None
- `duration: {}` (empty dict) → convert to None
- `ad_policy: {}` (empty dict) → convert to None
- `pricing_zones: {"HU": {}}` (incomplete zone) → skip/remove empty zones
"""
if not isinstance(data, dict):
return data
# ── Sanitize nested dicts: convert empty dicts to None ──
empty_dict_keys = [
"pricing", "pricing_zones", "allowances", "lifecycle",
"duration", "renewal", "ad_policy", "affiliate", "marketing",
]
for key in empty_dict_keys:
val = data.get(key)
if val is not None and isinstance(val, dict) and len(val) == 0:
data[key] = None
# ── Handle legacy type values (e.g. "base", empty string) ──
raw_type = data.get("type")
if raw_type is not None and isinstance(raw_type, str):
raw_lower = raw_type.strip().lower()
if raw_lower not in ("private", "corporate", "addon"):
# Legacy "base" → map to "private" for backward compat
# or just set to None to let the default apply
if raw_lower == "base":
data["type"] = "private"
else:
data["type"] = "private"
elif raw_type is None:
# Missing type → default to private
data["type"] = "private"
# ── Sanitize pricing zones: remove empty zones, ensure valid fields ──
zones = data.get("pricing_zones")
if isinstance(zones, dict):
cleaned_zones = {}
for code, zone_data in zones.items():
if isinstance(zone_data, dict):
# Provide defaults for missing fields
cleaned_zones[code] = {
"monthly_price": zone_data.get("monthly_price", 0) or 0,
"yearly_price": zone_data.get("yearly_price", 0) or 0,
"currency": zone_data.get("currency", "EUR") or "EUR",
"credit_price": zone_data.get("credit_price"),
}
data["pricing_zones"] = cleaned_zones if cleaned_zones else None
# ── Sanitize pricing (legacy): ensure required fields exist ──
legacy_pricing = data.get("pricing")
if isinstance(legacy_pricing, dict):
if "monthly_price" not in legacy_pricing or legacy_pricing.get("monthly_price") is None:
legacy_pricing["monthly_price"] = 0
if "yearly_price" not in legacy_pricing or legacy_pricing.get("yearly_price") is None:
legacy_pricing["yearly_price"] = 0
if "currency" not in legacy_pricing or not legacy_pricing.get("currency"):
legacy_pricing["currency"] = "EUR"
# ── Sanitize lifecycle: ensure at least is_public exists ──
lifecycle = data.get("lifecycle")
if isinstance(lifecycle, dict):
if "is_public" not in lifecycle:
lifecycle["is_public"] = True
# ── Sanitize allowances: ensure all numeric fields have defaults ──
allowances = data.get("allowances")
if isinstance(allowances, dict):
allowances.setdefault("max_vehicles", 0)
allowances.setdefault("max_garages", 0)
allowances.setdefault("max_users", 0)
allowances.setdefault("monthly_free_credits", 0)
# ── Sanitize renewal: ensure all fields have defaults ──
renewal = data.get("renewal")
if isinstance(renewal, dict):
renewal.setdefault("enabled", False)
renewal.setdefault("auto_renew_default", False)
renewal.setdefault("retry_on_failure", True)
renewal.setdefault("max_retry_count", 3)
renewal.setdefault("grace_period_days", 7)
if "renewal_period_days" not in renewal:
renewal["renewal_period_days"] = None
return data
# ── API Response / Request modellek ───────────────────────────────────────────
@@ -222,6 +335,36 @@ class SubscriptionTierUpdate(BaseModel):
return v.strip().lower() if v else v
class SubscriptionDetailResponse(BaseModel):
"""
Részletes előfizetés adatok válasz a frontend számára.
Tartalmazza a subscription rekord mezőit, beleértve az auto-renewal adatokat.
"""
tier_id: int
tier_name: str
display_name: str
valid_from: Optional[str] = None
valid_until: Optional[str] = None
is_active: bool
# ── P0 AUTO-RENEWAL FIELDS ──
auto_renew: bool = Field(default=False, description="Auto-renewal engedélyezve")
next_renewal_date: Optional[str] = Field(default=None, description="Következő auto-renewal dátuma")
wallet_auto_deduct: bool = Field(default=False, description="Auto-levonás a walletből megújításkor")
renewal_failure_count: int = Field(default=0, description="Sikertelen megújítási kísérletek száma")
provider_subscription_id: Optional[str] = Field(default=None, description="Külső fizetési gateway subscription ID")
# ── Tier metadata ──
allowances: dict = Field(default_factory=dict)
pricing: dict = Field(default_factory=dict)
duration: Optional[dict] = None
renewal: Optional[dict] = Field(default=None, description="Tier renewal konfiguráció a rules-ból")
ad_policy: Optional[dict] = None
entitlements: list = Field(default_factory=list)
feature_capabilities: dict = Field(default_factory=dict)
extra_allowances: dict = Field(default_factory=dict)
model_config = ConfigDict(from_attributes=True)
class SubscriptionTierListResponse(BaseModel):
"""Csomagok listázásának válaszformátuma (GET /)."""
total: int

View File

@@ -1,6 +1,6 @@
# /opt/docker/dev/service_finder/backend/app/schemas/user.py
import uuid
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
from pydantic import BaseModel, EmailStr, Field, field_validator, ConfigDict
from typing import Optional, Any, Dict
from datetime import date, datetime
@@ -43,6 +43,17 @@ class UserResponse(UserBase):
# P0: Real subscription limits from the assigned subscription_tier JSONB rules
max_vehicles: int = 1
max_garages: int = 1
# ── P0 Subscription Card Upgrade ──
subscription_display_name: Optional[str] = None
subscription_tier_id: Optional[int] = None
subscription_valid_from: Optional[str] = None
# ── P0 AUTO-RENEWAL FIELDS ──
subscription_auto_renew: bool = False
subscription_next_renewal_date: Optional[str] = None
subscription_wallet_auto_deduct: bool = False
# ── P0 Flip Card ──
active_addons: list = Field(default_factory=list, description="Aktív add-on előfizetések")
# ── End of subscription fields ──
scope_level: str
scope_id: Optional[str] = None
ui_mode: str = "personal"

View File

@@ -0,0 +1,307 @@
"""
Downgrade Executor — Background service that activates pending downgrades.
Processes subscriptions where:
- pending_tier_id IS NOT NULL (a downgrade was requested)
- valid_until < NOW() (the current paid period has expired)
When both conditions are met:
1. Deactivates the current subscription (is_active = False)
2. Activates the pending tier (creates a new UserSubscription/OrgSubscription)
3. Clears the pending_tier_id and pending_activated_at fields
THOUGHT PROCESS:
- Designed to be called from a CRON job or scheduled task.
- Uses atomic SELECT ... FOR UPDATE SKIP LOCKED to prevent duplicate
activation when multiple workers run concurrently.
- Updates User.subscription_plan and User.subscription_expires_at for
backward compatibility.
- All operations are logged at INFO level for audit trail.
"""
import logging
from datetime import datetime, timedelta
from typing import List, Tuple, Optional
from sqlalchemy import select, update, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.core_logic import (
SubscriptionTier,
UserSubscription,
OrganizationSubscription,
)
from app.models.identity.identity import User
logger = logging.getLogger("downgrade-executor")
class DowngradeExecutor:
"""
Background service that activates pending downgrades after the current
subscription period expires.
Usage:
executor = DowngradeExecutor()
count = await executor.process_pending_downgrades(db)
"""
# ──────────────────────────────────────────────────────────────────────────
# Public API
# ──────────────────────────────────────────────────────────────────────────
async def process_pending_downgrades(
self,
db: AsyncSession,
batch_size: int = 50,
) -> Tuple[int, int]:
"""
Process all pending downgrades where the current subscription has expired.
Uses atomic SELECT ... FOR UPDATE SKIP LOCKED to safely handle
concurrent execution.
Args:
db: Database session.
batch_size: Maximum number of downgrades to process in one call.
Returns:
Tuple of (processed_count, error_count).
"""
now = datetime.utcnow()
processed = 0
errors = 0
# ── 1. Process UserSubscription pending downgrades ──────────────
user_subs = await self._fetch_expired_user_pending(db, now, batch_size)
for sub in user_subs:
try:
await self._activate_user_pending(db, sub)
processed += 1
logger.info(
"User pending downgrade activated: user_id=%d "
"from_tier_id=%d to_tier_id=%d",
sub.user_id, sub.tier_id, sub.pending_tier_id,
)
except Exception as e:
errors += 1
logger.exception(
"Failed to activate user pending downgrade: sub_id=%d "
"user_id=%d error=%s",
sub.id, sub.user_id, str(e),
)
# ── 2. Process OrganizationSubscription pending downgrades ─────
org_subs = await self._fetch_expired_org_pending(db, now, batch_size)
for sub in org_subs:
try:
await self._activate_org_pending(db, sub)
processed += 1
logger.info(
"Org pending downgrade activated: org_id=%d "
"from_tier_id=%d to_tier_id=%d",
sub.org_id, sub.tier_id, sub.pending_tier_id,
)
except Exception as e:
errors += 1
logger.exception(
"Failed to activate org pending downgrade: sub_id=%d "
"org_id=%d error=%s",
sub.id, sub.org_id, str(e),
)
if processed > 0 or errors > 0:
logger.info(
"DowngradeExecutor finished: processed=%d errors=%d",
processed, errors,
)
return (processed, errors)
# ──────────────────────────────────────────────────────────────────────────
# Internal: Fetch expired pending subscriptions
# ──────────────────────────────────────────────────────────────────────────
async def _fetch_expired_user_pending(
self,
db: AsyncSession,
now: datetime,
limit: int,
) -> List[UserSubscription]:
"""
Fetch UserSubscription records where:
- pending_tier_id IS NOT NULL
- valid_until < now (expired)
- is_active == True
Uses FOR UPDATE SKIP LOCKED for atomic processing.
"""
stmt = (
select(UserSubscription)
.where(
UserSubscription.pending_tier_id.is_not(None),
UserSubscription.valid_until.is_not(None),
UserSubscription.valid_until < now,
UserSubscription.is_active == True,
)
.order_by(UserSubscription.valid_until.asc())
.limit(limit)
.with_for_update(skip_locked=True)
)
result = await db.execute(stmt)
return list(result.scalars().all())
async def _fetch_expired_org_pending(
self,
db: AsyncSession,
now: datetime,
limit: int,
) -> List[OrganizationSubscription]:
"""
Fetch OrganizationSubscription records where:
- pending_tier_id IS NOT NULL
- valid_until < now (expired)
- is_active == True
Uses FOR UPDATE SKIP LOCKED for atomic processing.
"""
stmt = (
select(OrganizationSubscription)
.where(
OrganizationSubscription.pending_tier_id.is_not(None),
OrganizationSubscription.valid_until.is_not(None),
OrganizationSubscription.valid_until < now,
OrganizationSubscription.is_active == True,
)
.order_by(OrganizationSubscription.valid_until.asc())
.limit(limit)
.with_for_update(skip_locked=True)
)
result = await db.execute(stmt)
return list(result.scalars().all())
# ──────────────────────────────────────────────────────────────────────────
# Internal: Activate pending downgrades
# ──────────────────────────────────────────────────────────────────────────
async def _activate_user_pending(
self,
db: AsyncSession,
current_sub: UserSubscription,
) -> UserSubscription:
"""
Activate a pending user-level downgrade.
1. Deactivates the current subscription
2. Fetches the pending tier to get duration info
3. Creates a new UserSubscription for the pending tier
4. Updates User.subscription_plan for backward compat
5. Clears pending fields on the old subscription
"""
target_tier_id = current_sub.pending_tier_id
if target_tier_id is None:
raise ValueError(f"Subscription {current_sub.id} has no pending_tier_id")
# Fetch the target tier for duration info
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == target_tier_id)
tier_result = await db.execute(tier_stmt)
tier = tier_result.scalar_one_or_none()
if not tier:
raise ValueError(f"Pending tier {target_tier_id} not found")
# Resolve duration from tier.rules
duration_days = 30
if tier.rules:
duration_config = tier.rules.get("duration", {})
if isinstance(duration_config, dict):
days = duration_config.get("days", 30)
if isinstance(days, (int, float)) and days > 0:
duration_days = int(days)
now = datetime.utcnow()
valid_until = now + timedelta(days=duration_days)
# Deactivate current subscription
current_sub.is_active = False
# Create new subscription for the pending tier
new_sub = UserSubscription(
user_id=current_sub.user_id,
tier_id=target_tier_id,
valid_from=now,
valid_until=valid_until,
is_active=True,
)
db.add(new_sub)
# Update User.subscription_plan for backward compat
user_stmt = select(User).where(User.id == current_sub.user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one_or_none()
if user:
user.subscription_plan = tier.name
user.subscription_expires_at = valid_until
# Clear pending fields on old subscription
current_sub.pending_tier_id = None
current_sub.pending_activated_at = None
await db.flush()
await db.refresh(new_sub)
return new_sub
async def _activate_org_pending(
self,
db: AsyncSession,
current_sub: OrganizationSubscription,
) -> OrganizationSubscription:
"""
Activate a pending org-level downgrade.
Same logic as _activate_user_pending but for org subscriptions.
"""
target_tier_id = current_sub.pending_tier_id
if target_tier_id is None:
raise ValueError(f"Subscription {current_sub.id} has no pending_tier_id")
# Fetch the target tier
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == target_tier_id)
tier_result = await db.execute(tier_stmt)
tier = tier_result.scalar_one_or_none()
if not tier:
raise ValueError(f"Pending tier {target_tier_id} not found")
# Resolve duration
duration_days = 30
if tier.rules:
duration_config = tier.rules.get("duration", {})
if isinstance(duration_config, dict):
days = duration_config.get("days", 30)
if isinstance(days, (int, float)) and days > 0:
duration_days = int(days)
now = datetime.utcnow()
valid_until = now + timedelta(days=duration_days)
# Deactivate current
current_sub.is_active = False
# Create new subscription
new_sub = OrganizationSubscription(
org_id=current_sub.org_id,
tier_id=target_tier_id,
valid_from=now,
valid_until=valid_until,
is_active=True,
)
db.add(new_sub)
# Clear pending fields
current_sub.pending_tier_id = None
current_sub.pending_activated_at = None
await db.flush()
await db.refresh(new_sub)
return new_sub

View File

@@ -51,6 +51,7 @@ from app.schemas.commission import (
CommissionDistributionResponse,
)
from app.services import commission_service
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger("financial-manager")
@@ -80,9 +81,11 @@ class PurchaseResult:
currency: str = "EUR",
gateway: str = "mock",
gateway_intent_id: Optional[str] = None,
checkout_url: Optional[str] = None,
commission_result: Optional[CommissionDistributionResponse] = None,
error: Optional[str] = None,
is_org_subscription: bool = False,
payment_status: Optional[str] = None,
):
self.success = success
self.payment_intent_id = payment_intent_id
@@ -95,9 +98,11 @@ class PurchaseResult:
self.currency = currency
self.gateway = gateway
self.gateway_intent_id = gateway_intent_id
self.checkout_url = checkout_url
self.commission_result = commission_result
self.error = error
self.is_org_subscription = is_org_subscription
self.payment_status = payment_status
def to_dict(self) -> Dict[str, Any]:
"""Serialize to a dict for API response."""
@@ -115,6 +120,12 @@ class PurchaseResult:
"gateway_intent_id": self.gateway_intent_id,
"is_org_subscription": self.is_org_subscription,
}
# Include checkout_url for payment redirect (simulate_redirect mode)
if self.checkout_url:
result["checkout_url"] = self.checkout_url
# Include payment_status for pending payments
if self.payment_status:
result["payment_status"] = self.payment_status
if self.commission_result:
result["commission"] = {
"total_commission": self.commission_result.total_commission,
@@ -225,6 +236,89 @@ class FinancialManager:
# ── Step 2: Calculate price ────────────────────────────────────
price = await self._calculate_price(db, tier, region_code, currency)
# ══════════════════════════════════════════════════════════════════
# Step 2b: FREE TIER BYPASS & DOWNGRADE DETECTION (Issue #429)
# ══════════════════════════════════════════════════════════════════
#
# THOUGHT PROCESS:
# - Free tiers (price=0) cause PaymentRouter to throw
# ValueError("net_amount pozitív szám kell legyen").
# - Solution: skip PaymentIntent creation entirely for $0 tiers.
# - If downgrade (target tier_level < current): set pending_tier_id.
# - If upgrade/same-level with $0: activate immediately.
# - Paid tiers (price > 0): fall through to normal payment flow.
# ══════════════════════════════════════════════════════════════════
if price <= 0:
logger.info(
"Zero-cost tier detected (price=%.2f). Checking downgrade "
"for user_id=%d tier_id=%d", price, user_id, tier_id,
)
is_down = await SubscriptionService.is_downgrade(
db=db, user_id=user_id,
target_tier_id=tier_id, active_org_id=org_id,
)
if is_down:
# ── DOWNGRADE PATH: set pending_tier_id ──
return await self._handle_downgrade(
db=db, user_id=user_id, tier_id=tier_id,
org_id=org_id, tier=tier, currency=currency,
)
# ── UPGRADE OR SAME-LEVEL: activate immediately ──
# Clear any existing pending downgrade first
await self._clear_pending_downgrade(db, user_id, org_id)
if org_id:
subscription = await self.subscription_activator.activate_org_subscription(
db=db, org_id=org_id, tier_id=tier_id,
duration_days=duration_days,
)
is_org = True
else:
subscription = await self.subscription_activator.activate_user_subscription(
db=db, user_id=user_id, tier_id=tier_id,
duration_days=duration_days,
)
is_org = False
commission_result = await self._distribute_commission(
db=db, buyer_user_id=user_id,
transaction_amount=0, region_code=region_code,
)
await db.commit()
logger.info(
"Free tier activation COMPLETED: user_id=%d tier=%s sub_id=%d",
user_id, tier.name, subscription.id,
)
return PurchaseResult(
success=True, subscription_id=subscription.id,
tier_name=tier.name,
valid_from=subscription.valid_from,
valid_until=subscription.valid_until,
amount_paid=0, currency=currency,
gateway="none", is_org_subscription=is_org,
)
# ══════════════════════════════════════════════════════════════════
# PAID TIER FLOW (price > 0)
# ══════════════════════════════════════════════════════════════════
#
# THOUGHT PROCESS:
# The payment gateway may return either:
# a) "completed" status (auto_approve mode) — activate immediately
# b) "requires_action" status (simulate_redirect mode) —
# return a checkout_url; subscription activation is deferred
# until the mock webhook callback is received.
#
# In case (b), the caller (frontend) receives the checkout_url and
# must redirect the user. After the user clicks "Pay Now" on the
# mock page, the webhook callback (POST /billing/mock-payment/callback)
# finalizes the PaymentIntent and activates the subscription.
# ══════════════════════════════════════════════════════════════════
# ── Step 3: Create PaymentIntent ───────────────────────────────
payment_intent = await self._create_payment_intent(
db=db,
@@ -242,15 +336,87 @@ class FinancialManager:
"payment_intent_id": payment_intent.id,
"tier_id": tier_id,
"user_id": user_id,
"org_id": org_id,
"duration_days": duration_days,
"region_code": region_code,
**(metadata or {}),
},
)
# ── Step 4b: Handle gateway response ──────────────────────────
gateway_status = gateway_result.get("status", "completed")
if gateway_status == "requires_action":
# ════════════════════════════════════════════════════════════
# simulate_redirect mode — defer subscription activation
# ════════════════════════════════════════════════════════════
#
# The gateway tells us the user needs to complete payment
# on an external (mock) checkout page. We:
# 1. Store the gateway intent ID on the PaymentIntent
# 2. Store tier metadata for later webhook processing
# 3. Leave PaymentIntent as PENDING
# 4. Do NOT activate the subscription yet
# 5. Return checkout_url so the frontend can redirect
#
# Subscription activation happens when the mock webhook
# callback is received (see billing.py mock-payment/callback).
payment_intent.stripe_session_id = gateway_result.get("id")
# Store the full purchase context in metadata so the webhook
# callback can complete the activation
payment_intent.meta_data = {
**(payment_intent.meta_data or {}),
"tier_id": tier_id,
"user_id": user_id,
"org_id": org_id,
"duration_days": duration_days,
"region_code": region_code,
"checkout_url": gateway_result.get("checkout_url"),
}
# Flush so the PaymentIntent is persisted before returning
await db.flush()
checkout_url = gateway_result.get("checkout_url")
gateway_intent_id = gateway_result.get("id")
logger.info(
"[MOCK_PAYMENT_REDIRECT] Payment requires user action: "
"user_id=%d tier=%s amount=%.2f checkout_url=%s "
"intent_id=%s",
user_id, tier.name, price, checkout_url, gateway_intent_id,
)
# Clear any existing pending downgrade (user is upgrading)
await self._clear_pending_downgrade(db, user_id, org_id)
await db.commit()
return PurchaseResult(
success=True,
payment_intent_id=payment_intent.id,
tier_name=tier.name,
amount_paid=price,
currency=currency,
gateway=type(self.payment_gateway).__name__,
gateway_intent_id=gateway_intent_id,
checkout_url=checkout_url,
payment_status="PENDING_PAYMENT",
is_org_subscription=(org_id is not None),
)
# ════════════════════════════════════════════════════════════════
# auto_approve mode — immediate activation
# ════════════════════════════════════════════════════════════════
# Mark PaymentIntent as COMPLETED
payment_intent.status = PaymentIntentStatus.COMPLETED
payment_intent.stripe_session_id = gateway_result.get("id")
payment_intent.completed_at = datetime.utcnow()
# Clear any existing pending downgrade on upgrade
await self._clear_pending_downgrade(db, user_id, org_id)
# ── Step 5: Activate subscription ──────────────────────────────
if org_id:
subscription = await self.subscription_activator.activate_org_subscription(
@@ -281,7 +447,7 @@ class FinancialManager:
await db.commit()
logger.info(
"Purchase flow COMPLETED: user_id=%d tier=%s amount=%.2f "
"Purchase flow COMPLETED (immediate): user_id=%d tier=%s amount=%.2f "
"subscription_id=%d commission_total=%.2f",
user_id, tier.name, price,
subscription.id,
@@ -300,6 +466,7 @@ class FinancialManager:
currency=currency,
gateway=type(self.payment_gateway).__name__,
gateway_intent_id=gateway_result.get("id"),
payment_status="COMPLETED",
commission_result=commission_result,
is_org_subscription=is_org,
)
@@ -517,3 +684,161 @@ class FinancialManager:
)
# Commission failure should not block the purchase
return None
# ──────────────────────────────────────────────────────────────────────────
# Pending Downgrade Helpers (Issue #429)
# ──────────────────────────────────────────────────────────────────────────
async def _handle_downgrade(
self,
db: AsyncSession,
user_id: int,
tier_id: int,
org_id: Optional[int],
tier: SubscriptionTier,
currency: str,
) -> PurchaseResult:
"""
Handle a downgrade request by setting pending_tier_id on the current
active subscription. The downgrade takes effect only after the current
paid period expires.
THOUGHT PROCESS:
- Finds the currently active subscription (user or org level).
- Sets pending_tier_id to the target tier ID.
- Sets pending_activated_at to now (audit trail).
- The actual tier switch is deferred to the DowngradeExecutor service.
- Returns a success PurchaseResult with the CURRENT subscription data.
Args:
db: Database session.
user_id: The user requesting the downgrade.
tier_id: The target SubscriptionTier ID (cheaper/free tier).
org_id: Optional org ID for org-level subscriptions.
tier: The target SubscriptionTier object (for tier_name).
currency: Currency code.
Returns:
PurchaseResult with success=True and current subscription data.
"""
if org_id:
from app.models.core_logic import OrganizationSubscription
stmt = select(OrganizationSubscription).where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True,
).order_by(OrganizationSubscription.id.desc()).limit(1)
result = await db.execute(stmt)
current_sub = result.scalar_one_or_none()
else:
from app.models.core_logic import UserSubscription
stmt = select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True,
).order_by(UserSubscription.id.desc()).limit(1)
result = await db.execute(stmt)
current_sub = result.scalar_one_or_none()
if not current_sub:
# No active subscription — activate the downgrade immediately
logger.info(
"No active subscription for user_id=%d. Activating downgrade immediately.",
user_id,
)
if org_id:
new_sub = await self.subscription_activator.activate_org_subscription(
db=db, org_id=org_id, tier_id=tier_id,
)
else:
new_sub = await self.subscription_activator.activate_user_subscription(
db=db, user_id=user_id, tier_id=tier_id,
)
await db.commit()
return PurchaseResult(
success=True, subscription_id=new_sub.id,
tier_name=tier.name,
valid_from=new_sub.valid_from,
valid_until=new_sub.valid_until,
amount_paid=0, currency=currency,
gateway="none",
is_org_subscription=(org_id is not None),
)
# Set pending tier on existing active subscription
current_sub.pending_tier_id = tier_id
current_sub.pending_activated_at = datetime.utcnow()
await db.flush()
await db.refresh(current_sub)
logger.info(
"Pending downgrade set: %s_id=%d current_tier_id=%d "
"pending_tier_id=%d valid_until=%s",
"org" if org_id else "user",
org_id or user_id,
current_sub.tier_id, tier_id,
current_sub.valid_until,
)
await db.commit()
return PurchaseResult(
success=True,
subscription_id=current_sub.id,
tier_name=tier.name,
valid_from=current_sub.valid_from,
valid_until=current_sub.valid_until,
amount_paid=0,
currency=currency,
gateway="none",
is_org_subscription=(org_id is not None),
)
async def _clear_pending_downgrade(
self,
db: AsyncSession,
user_id: int,
org_id: Optional[int],
) -> None:
"""
Clear any pending downgrade on the user's or org's subscriptions.
Called when an upgrade or new purchase occurs.
THOUGHT PROCESS:
- Scans both active and recently deactivated subscriptions for
pending_tier_id IS NOT NULL.
- Sets pending_tier_id = NULL and pending_activated_at = NULL.
- This ensures that an upgrade overrides a previously scheduled downgrade.
Args:
db: Database session.
user_id: The user ID.
org_id: Optional org ID.
"""
from app.models.core_logic import UserSubscription, OrganizationSubscription
if org_id:
stmt = select(OrganizationSubscription).where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.pending_tier_id.is_not(None),
)
else:
stmt = select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.pending_tier_id.is_not(None),
)
result = await db.execute(stmt)
subs_with_pending = result.scalars().all()
for sub in subs_with_pending:
sub.pending_tier_id = None
sub.pending_activated_at = None
logger.debug(
"Cleared pending downgrade on subscription id=%d", sub.id,
)
if subs_with_pending:
logger.info(
"Cleared %d pending downgrade(s) for %s_id=%d",
len(subs_with_pending), "org" if org_id else "user",
org_id or user_id,
)

View File

@@ -33,37 +33,44 @@ class MockPaymentGateway(BasePaymentGateway):
Mock payment gateway for development and testing.
Modes:
- auto_approve (default): All payments succeed immediately.
- simulate_redirect (NEW default): Returns a checkout URL for paid tiers.
The frontend redirects to the mock checkout page; after user clicks "Pay Now",
a webhook callback marks the PaymentIntent as COMPLETED.
- auto_approve: All payments succeed immediately (legacy).
- simulate_failure: All payments fail with a configurable error message.
- simulate_timeout: Payments hang until a configurable timeout then succeed.
Usage:
gateway = MockPaymentGateway(mode="auto_approve")
gateway = MockPaymentGateway(mode="simulate_redirect")
result = await gateway.create_intent(Decimal("100.00"), "EUR")
"""
def __init__(
self,
mode: str = "auto_approve",
mode: str = "simulate_redirect",
failure_message: str = "Mock payment declined (simulated failure)",
timeout_seconds: int = 5,
base_url: str = "http://localhost:8000",
):
"""
Initialize the mock gateway.
Args:
mode: Operation mode — "auto_approve", "simulate_failure", or "simulate_timeout".
mode: Operation mode — "simulate_redirect" (default), "auto_approve",
"simulate_failure", or "simulate_timeout".
failure_message: Custom error message for simulate_failure mode.
timeout_seconds: Simulated processing delay for simulate_timeout mode.
base_url: Base URL for generating mock checkout URLs.
"""
self.mode = mode
self.failure_message = failure_message
self.timeout_seconds = timeout_seconds
self.base_url = base_url
self._processed_intents: Dict[str, Dict[str, Any]] = {}
logger.info(
"MockPaymentGateway initialized: mode=%s, timeout=%ds",
self.mode, self.timeout_seconds,
"MockPaymentGateway initialized: mode=%s, timeout=%ds, base_url=%s",
self.mode, self.timeout_seconds, self.base_url,
)
# ──────────────────────────────────────────────────────────────────────────
@@ -80,28 +87,46 @@ class MockPaymentGateway(BasePaymentGateway):
"""
Create a mock payment intent.
In auto_approve mode, immediately returns a "completed" intent.
In simulate_failure mode, raises PaymentGatewayError.
In simulate_timeout mode, logs a warning and returns "processing".
Modes:
- simulate_redirect (default): For paid amounts (>0), returns a checkout URL
with status "requires_action". The frontend redirects the user to this URL.
For $0 amounts, falls through to auto_approve.
- auto_approve: Immediately returns a "completed" intent.
- simulate_failure: Raises PaymentGatewayError.
- simulate_timeout: Logs a warning and returns "processing".
THOUGHT PROCESS:
The simulate_redirect mode bridges the gap between "instant success"
and a realistic payment flow. The returned checkout_url points to a
mock page served by our own backend. When the user clicks "Pay Now",
the mock page POSTs to a webhook callback endpoint that finalizes
the PaymentIntent. This allows frontend testing of the full redirect
→ callback → success lifecycle without Stripe.
Args:
amount: The payment amount.
currency: ISO 4217 currency code (default: EUR).
metadata: Optional metadata dict.
**kwargs: Additional parameters (ignored in mock).
**kwargs: Supports 'base_url' override for the checkout URL base.
Returns:
Dict with mock payment intent details.
Dict with mock payment intent details:
- simulate_redirect: {id, status: "requires_action", checkout_url, ...}
- auto_approve: {id, status: "completed", ...}
Raises:
PaymentGatewayError: In simulate_failure mode.
"""
intent_id = f"mock_intent_{uuid.uuid4().hex[:12]}"
# ── Strict console log for audit trail ──────────────────────────────
logger.info(
"Mock create_intent: id=%s amount=%s %s mode=%s",
intent_id, amount, currency, self.mode,
"[MOCK_PAYMENT_REQUEST] Initiating transaction with external gateway "
"for amount: %s %s, mode=%s, intent_id=%s",
amount, currency, self.mode, intent_id,
)
# ── simulate_failure: Always fail ───────────────────────────────────
if self.mode == "simulate_failure":
logger.warning(
"Mock payment FAILURE: intent=%s reason='%s'",
@@ -109,6 +134,7 @@ class MockPaymentGateway(BasePaymentGateway):
)
raise PaymentGatewayError(self.failure_message)
# ── simulate_timeout: Pretend to hang then return "processing" ──────
if self.mode == "simulate_timeout":
logger.warning(
"Mock payment TIMEOUT simulation: intent=%s delay=%ds",
@@ -117,19 +143,59 @@ class MockPaymentGateway(BasePaymentGateway):
# In a real scenario we'd sleep; here we just return "processing"
# so the caller can handle the pending state.
# ── simulate_redirect: Return a checkout URL for paid tiers ─────────
# For $0 amounts, fall through to auto_approve behaviour.
if self.mode == "simulate_redirect" and amount > 0:
# Use the base_url from kwargs if provided, otherwise use self.base_url
base_url = kwargs.get("base_url", self.base_url)
# Generate the mock checkout page URL — the user will be redirected
# here to complete the payment. After clicking "Pay Now", the mock
# page POSTs to /billing/mock-payment/callback which updates the
# PaymentIntent from PENDING → COMPLETED.
checkout_url = f"{base_url}/api/v1/billing/mock-payment/checkout/{intent_id}"
intent_data = {
"id": intent_id,
"status": "requires_action",
"amount": float(amount),
"currency": currency,
"checkout_url": checkout_url,
"method": "GET", # Frontend opens this URL in a new tab/window
"created_at": datetime.utcnow().isoformat(),
"completed_at": None,
"metadata": metadata or {},
"gateway": "mock",
}
self._processed_intents[intent_id] = intent_data
logger.info(
"[MOCK_PAYMENT_REDIRECT] Checkout URL generated: intent=%s "
"checkout_url=%s amount=%s %s",
intent_id, checkout_url, amount, currency,
)
return intent_data
# ── auto_approve OR $0 amount: Immediate completion ─────────────────
now = datetime.utcnow()
intent_data = {
"id": intent_id,
"status": "completed" if self.mode == "auto_approve" else "processing",
"status": "completed",
"amount": float(amount),
"currency": currency,
"created_at": now.isoformat(),
"completed_at": now.isoformat() if self.mode == "auto_approve" else None,
"completed_at": now.isoformat(),
"metadata": metadata or {},
"gateway": "mock",
}
self._processed_intents[intent_id] = intent_data
logger.info(
"Mock payment AUTO-APPROVED: intent=%s amount=%s %s",
intent_id, amount, currency,
)
return intent_data
async def verify_payment(
@@ -228,9 +294,10 @@ class MockPaymentGateway(BasePaymentGateway):
Change the gateway's operating mode at runtime.
Args:
mode: "auto_approve", "simulate_failure", or "simulate_timeout".
mode: "simulate_redirect", "auto_approve", "simulate_failure",
or "simulate_timeout".
"""
valid_modes = {"auto_approve", "simulate_failure", "simulate_timeout"}
valid_modes = {"simulate_redirect", "auto_approve", "simulate_failure", "simulate_timeout"}
if mode not in valid_modes:
raise ValueError(
f"Invalid mock mode '{mode}'. Valid modes: {valid_modes}"

View File

@@ -333,6 +333,14 @@ class SubscriptionService:
"valid_from": org_sub.valid_from.isoformat() if org_sub.valid_from else None,
"valid_until": org_sub.valid_until.isoformat() if org_sub.valid_until else None,
"is_active": org_sub.is_active,
# ── P0 AUTO-RENEWAL FIELDS ──
"auto_renew": org_sub.auto_renew,
"next_renewal_date": org_sub.next_renewal_date.isoformat() if org_sub.next_renewal_date else None,
"wallet_auto_deduct": org_sub.wallet_auto_deduct,
"renewal_failure_count": org_sub.renewal_failure_count,
"provider_subscription_id": org_sub.provider_subscription_id,
"renewal": rules.get("renewal", {}),
# ── Existing fields ──
"allowances": rules.get("allowances", {}),
"pricing": rules.get("pricing", {}),
"duration": rules.get("duration", {}),
@@ -392,6 +400,14 @@ class SubscriptionService:
"valid_from": user_sub.valid_from.isoformat() if user_sub.valid_from else None,
"valid_until": user_sub.valid_until.isoformat() if user_sub.valid_until else None,
"is_active": user_sub.is_active,
# ── P0 AUTO-RENEWAL FIELDS ──
"auto_renew": user_sub.auto_renew,
"next_renewal_date": user_sub.next_renewal_date.isoformat() if user_sub.next_renewal_date else None,
"wallet_auto_deduct": user_sub.wallet_auto_deduct,
"renewal_failure_count": user_sub.renewal_failure_count,
"provider_subscription_id": user_sub.provider_subscription_id,
"renewal": rules.get("renewal", {}),
# ── Existing fields ──
"allowances": rules.get("allowances", {}),
"pricing": rules.get("pricing", {}),
"duration": rules.get("duration", {}),
@@ -471,6 +487,65 @@ class SubscriptionService:
return visible
@staticmethod
async def is_downgrade(
db: AsyncSession,
user_id: int,
target_tier_id: int,
active_org_id: Optional[int] = None,
) -> bool:
"""
Determine if switching to the target tier constitutes a downgrade.
A downgrade is when the target tier's `tier_level` is LESS than the
user's CURRENT tier's `tier_level`. Upgrades (same or higher level)
should be activated immediately; downgrades should be deferred.
THOUGHT PROCESS:
- Uses `get_user_tier()` which resolves the effective tier from
UserSubscription, OrganizationSubscription, or fallback.
- Compares integer `tier_level` (0=free, 1=premium, 2=enterprise).
- Returns True when target_level < current_level (downgrade).
- For equal levels or upgrades, returns False (immediate activation).
Args:
db: Database session.
user_id: The user making the change.
target_tier_id: The SubscriptionTier ID they want to switch to.
active_org_id: Optional org ID to determine subscription context.
Returns:
True if this is a downgrade (should be deferred).
False if this is an upgrade or same-level (immediate activation).
Raises:
ValueError: If the target tier does not exist.
"""
# 1. Get the target tier's level
target_stmt = select(SubscriptionTier.tier_level).where(
SubscriptionTier.id == target_tier_id
)
target_result = await db.execute(target_stmt)
target_level = target_result.scalar_one_or_none()
if target_level is None:
raise ValueError(f"SubscriptionTier with id={target_tier_id} not found")
# 2. Get the current effective tier level via the existing resolver
current_tier_name = await SubscriptionService.get_user_tier(db, user_id)
current_level = SubscriptionService._get_user_level(current_tier_name)
# 3. Compare: downgrade if target < current
is_down = target_level < current_level
logger.info(
"Downgrade check: user_id=%d target_tier_id=%d "
"current_level=%d target_level=%d is_downgrade=%s",
user_id, target_tier_id, current_level, target_level, is_down,
)
return is_down
@staticmethod
def require_tier(user_tier: str, required_feature_or_min_tier: str) -> bool:
"""