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

@@ -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,
)