pénzügyi modul továbbfejlesztése (csomagkezelés)
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 • 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",
|
||||
})
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
307
backend/app/services/downgrade_executor.py
Normal file
307
backend/app/services/downgrade_executor.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user