admin_nyelvi_javítas
This commit is contained in:
@@ -13,13 +13,17 @@ THOUGHT PROCESS:
|
||||
- The service uses async/await throughout for FastAPI compatibility.
|
||||
- 2-Level MLM: distribute_commission() resolves Gen1 (direct referrer) and
|
||||
Gen2 (upline) payouts using commission_percent and upline_commission_percent.
|
||||
- Phase 3: Conflict Detection — before creating/updating a rule, the service
|
||||
checks for overlapping active rules with the same tier/region/is_campaign
|
||||
combination. If a conflict exists and force_override is False, a 409 is
|
||||
returned. If force_override is True, the conflicting rule is deactivated.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List, Sequence
|
||||
from sqlalchemy import select, func, case, or_, and_, delete as sa_delete
|
||||
from sqlalchemy import select, func, case, or_, and_, delete as sa_delete, not_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -28,18 +32,69 @@ from app.models.marketplace.commission import (
|
||||
CommissionRuleType,
|
||||
CommissionTier,
|
||||
)
|
||||
from app.models.identity.identity import User
|
||||
from app.models.identity.identity import User, Wallet
|
||||
from app.models.system.audit import (
|
||||
FinancialLedger,
|
||||
LedgerEntryType,
|
||||
WalletType,
|
||||
LedgerStatus,
|
||||
)
|
||||
from app.schemas.commission import (
|
||||
CommissionRuleCreate,
|
||||
CommissionRuleUpdate,
|
||||
CommissionDistributionRequest,
|
||||
CommissionDistributionItem,
|
||||
CommissionDistributionResponse,
|
||||
ConflictingRuleInfo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Conflict Detection
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def check_rule_conflict(
|
||||
db: AsyncSession,
|
||||
rule_type: CommissionRuleType,
|
||||
tier: CommissionTier,
|
||||
region_code: str,
|
||||
is_campaign: bool,
|
||||
exclude_rule_id: Optional[int] = None,
|
||||
) -> Optional[CommissionRule]:
|
||||
"""
|
||||
Check if an active rule already exists with the exact same combination
|
||||
of tier, region_code, and is_campaign.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
rule_type: L1_REWARD or L2_COMMISSION.
|
||||
tier: The tier to check.
|
||||
region_code: The region code to check.
|
||||
is_campaign: Whether the rule is a campaign rule.
|
||||
exclude_rule_id: Optional rule ID to exclude from the check
|
||||
(used when updating an existing rule).
|
||||
|
||||
Returns:
|
||||
The conflicting CommissionRule if found, None otherwise.
|
||||
"""
|
||||
conditions = [
|
||||
CommissionRule.rule_type == rule_type,
|
||||
CommissionRule.tier == tier,
|
||||
CommissionRule.region_code == region_code,
|
||||
CommissionRule.is_campaign == is_campaign,
|
||||
CommissionRule.is_active == True,
|
||||
]
|
||||
if exclude_rule_id is not None:
|
||||
conditions.append(CommissionRule.id != exclude_rule_id)
|
||||
|
||||
stmt = select(CommissionRule).where(and_(*conditions)).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# CRUD Operations
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -51,7 +106,14 @@ async def create_commission_rule(
|
||||
admin_user_id: int,
|
||||
) -> CommissionRule:
|
||||
"""
|
||||
Create a new commission rule with validation.
|
||||
Create a new commission rule with validation and conflict detection.
|
||||
|
||||
If an active rule with the same tier/region/is_campaign combination exists
|
||||
and force_override is False, the conflicting rule info is returned instead
|
||||
(caller should raise 409 Conflict).
|
||||
|
||||
If force_override is True, the conflicting rule is deactivated (is_active=False)
|
||||
and the new rule is saved.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
@@ -59,8 +121,39 @@ async def create_commission_rule(
|
||||
admin_user_id: ID of the admin creating the rule.
|
||||
|
||||
Returns:
|
||||
The newly created CommissionRule instance.
|
||||
The newly created CommissionRule instance, or the conflicting rule
|
||||
if a conflict was detected and force_override was False.
|
||||
"""
|
||||
# ── Phase 3: Conflict Detection ──────────────────────────────────────
|
||||
conflicting = await check_rule_conflict(
|
||||
db,
|
||||
rule_type=CommissionRuleType(data.rule_type.value),
|
||||
tier=CommissionTier(data.tier.value),
|
||||
region_code=data.region_code,
|
||||
is_campaign=data.is_campaign,
|
||||
)
|
||||
|
||||
if conflicting:
|
||||
if not data.force_override:
|
||||
logger.warning(
|
||||
"Conflict detected: existing active rule id=%d conflicts with "
|
||||
"new rule (type=%s tier=%s region=%s campaign=%s). "
|
||||
"force_override=False, returning conflict.",
|
||||
conflicting.id, data.rule_type, data.tier,
|
||||
data.region_code, data.is_campaign,
|
||||
)
|
||||
return conflicting # Caller must raise 409
|
||||
|
||||
# Admin override: deactivate the conflicting rule
|
||||
logger.info(
|
||||
"Admin override: Deactivating overlapping rule ID %d "
|
||||
"(type=%s tier=%s region=%s campaign=%s) in favor of new rule.",
|
||||
conflicting.id, conflicting.rule_type, conflicting.tier,
|
||||
conflicting.region_code, conflicting.is_campaign,
|
||||
)
|
||||
conflicting.is_active = False
|
||||
db.add(conflicting)
|
||||
|
||||
rule = CommissionRule(
|
||||
rule_type=CommissionRuleType(data.rule_type.value),
|
||||
tier=CommissionTier(data.tier.value),
|
||||
@@ -71,6 +164,10 @@ async def create_commission_rule(
|
||||
upline_commission_percent=data.upline_commission_percent,
|
||||
renewal_commission_percent=data.renewal_commission_percent,
|
||||
commission_max_amount=data.commission_max_amount,
|
||||
# Phase 2: Szintenkénti plafonok és Gen2 megújítás
|
||||
gen1_max_amount=data.gen1_max_amount,
|
||||
gen2_max_amount=data.gen2_max_amount,
|
||||
gen2_renewal_percent=data.gen2_renewal_percent,
|
||||
is_campaign=data.is_campaign,
|
||||
start_date=data.start_date,
|
||||
end_date=data.end_date,
|
||||
@@ -95,9 +192,15 @@ async def update_commission_rule(
|
||||
data: CommissionRuleUpdate,
|
||||
) -> Optional[CommissionRule]:
|
||||
"""
|
||||
Partially update an existing commission rule.
|
||||
Partially update an existing commission rule with conflict detection.
|
||||
|
||||
Only the fields explicitly set in the update schema are applied.
|
||||
If the update would create a conflict with another active rule and
|
||||
force_override is False, the conflicting rule info is returned instead
|
||||
(caller should raise 409 Conflict).
|
||||
|
||||
If force_override is True, the conflicting rule is deactivated.
|
||||
|
||||
Returns None if the rule does not exist.
|
||||
"""
|
||||
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
|
||||
@@ -107,7 +210,64 @@ async def update_commission_rule(
|
||||
return None
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# ── Phase 3: Conflict Detection on update ────────────────────────────
|
||||
# Determine the effective values after the update
|
||||
effective_rule_type = (
|
||||
CommissionRuleType(data.rule_type.value)
|
||||
if data.rule_type is not None
|
||||
else rule.rule_type
|
||||
)
|
||||
effective_tier = (
|
||||
CommissionTier(data.tier.value)
|
||||
if data.tier is not None
|
||||
else rule.tier
|
||||
)
|
||||
effective_region = (
|
||||
data.region_code
|
||||
if data.region_code is not None
|
||||
else rule.region_code
|
||||
)
|
||||
effective_campaign = (
|
||||
data.is_campaign
|
||||
if data.is_campaign is not None
|
||||
else rule.is_campaign
|
||||
)
|
||||
|
||||
conflicting = await check_rule_conflict(
|
||||
db,
|
||||
rule_type=effective_rule_type,
|
||||
tier=effective_tier,
|
||||
region_code=effective_region,
|
||||
is_campaign=effective_campaign,
|
||||
exclude_rule_id=rule_id,
|
||||
)
|
||||
|
||||
if conflicting:
|
||||
if not data.force_override:
|
||||
logger.warning(
|
||||
"Conflict detected on update: existing active rule id=%d conflicts "
|
||||
"with update to rule id=%d (type=%s tier=%s region=%s campaign=%s). "
|
||||
"force_override=False, returning conflict.",
|
||||
conflicting.id, rule_id, effective_rule_type,
|
||||
effective_tier, effective_region, effective_campaign,
|
||||
)
|
||||
return conflicting # Caller must raise 409
|
||||
|
||||
# Admin override: deactivate the conflicting rule
|
||||
logger.info(
|
||||
"Admin override on update: Deactivating overlapping rule ID %d "
|
||||
"(type=%s tier=%s region=%s campaign=%s) in favor of rule ID %d.",
|
||||
conflicting.id, conflicting.rule_type, conflicting.tier,
|
||||
conflicting.region_code, conflicting.is_campaign, rule_id,
|
||||
)
|
||||
conflicting.is_active = False
|
||||
db.add(conflicting)
|
||||
|
||||
for field, value in update_data.items():
|
||||
# Skip force_override — it's not a model field
|
||||
if field == "force_override":
|
||||
continue
|
||||
# Map enum fields from schema enums to model enums
|
||||
if field == "rule_type" and value is not None:
|
||||
setattr(rule, field, CommissionRuleType(value.value))
|
||||
@@ -374,24 +534,132 @@ async def _calculate_commission(
|
||||
return round(commission, 2)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Phase 2: Wallet Integration Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _credit_commission_to_wallet(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
transaction_type: str,
|
||||
details: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Credit commission amount to the user's Wallet.earned_credits and record
|
||||
a FinancialLedger CREDIT entry.
|
||||
|
||||
Follows the proven pattern from GamificationService._add_earned_credits().
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The user receiving the commission.
|
||||
amount: The commission amount to credit.
|
||||
transaction_type: Label for the ledger entry (e.g. 'COMMISSION_GEN1').
|
||||
details: Optional JSON metadata for the ledger entry.
|
||||
"""
|
||||
stmt = select(Wallet).where(Wallet.user_id == user_id)
|
||||
wallet = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if not wallet:
|
||||
logger.error(
|
||||
"Cannot credit commission: Wallet not found for user_id=%d",
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
|
||||
decimal_amount = Decimal(str(amount))
|
||||
wallet.earned_credits += decimal_amount
|
||||
|
||||
ledger_entry = FinancialLedger(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
entry_type=LedgerEntryType.CREDIT,
|
||||
wallet_type=WalletType.EARNED,
|
||||
transaction_type=transaction_type,
|
||||
status=LedgerStatus.COMPLETED,
|
||||
details=details or {},
|
||||
)
|
||||
db.add(ledger_entry)
|
||||
|
||||
logger.info(
|
||||
"Commission credited: user_id=%d amount=%.2f type=%s wallet_balance=%s",
|
||||
user_id, amount, transaction_type, wallet.earned_credits,
|
||||
)
|
||||
|
||||
|
||||
async def _is_user_recently_active(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
inactivity_days: int = 180,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has been active within the last `inactivity_days` days.
|
||||
|
||||
A user is considered active if:
|
||||
1. Their subscription is active (subscription_expires_at > now), OR
|
||||
2. They have a FinancialLedger entry within the inactivity window.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The user to check.
|
||||
inactivity_days: Number of days of inactivity before considered inactive.
|
||||
|
||||
Returns:
|
||||
True if the user is recently active, False otherwise.
|
||||
"""
|
||||
# Check subscription status first
|
||||
user = await _lookup_user(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# If user has an active subscription, they are active
|
||||
if user.subscription_expires_at and user.subscription_expires_at > datetime.utcnow():
|
||||
return True
|
||||
|
||||
# Check if there's any FinancialLedger activity within the inactivity window
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=inactivity_days)
|
||||
stmt = select(FinancialLedger).where(
|
||||
FinancialLedger.user_id == user_id,
|
||||
FinancialLedger.created_at >= cutoff_date,
|
||||
).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
recent_activity = result.scalar_one_or_none()
|
||||
|
||||
return recent_activity is not None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Phase 2: Enhanced 2-Level MLM Commission Distribution Engine
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def distribute_commission(
|
||||
db: AsyncSession,
|
||||
request: CommissionDistributionRequest,
|
||||
) -> CommissionDistributionResponse:
|
||||
"""
|
||||
2-Level MLM Commission Distribution Engine.
|
||||
2-Level MLM Commission Distribution Engine (Phase 2 Enhanced).
|
||||
|
||||
When a referred company makes a purchase, this function:
|
||||
1. Looks up the buyer and their referrer (Gen1)
|
||||
2. Finds the active commission rule for Gen1's tier/region
|
||||
3. Calculates Gen1's commission using commission_percent
|
||||
4. If Gen1 has a referrer (Gen2/upline), calculates Gen2's commission
|
||||
using upline_commission_percent from the SAME rule
|
||||
- Uses gen1_max_amount as cap (fallback: commission_max_amount)
|
||||
4. If Gen1 has a referrer (Gen2/upline), checks Gen1's activity status:
|
||||
- If Gen1 is inactive (no activity for 180 days), Gen2 is NOT paid
|
||||
- If Gen1 is active, calculates Gen2's commission
|
||||
5. For renewals (is_renewal=True):
|
||||
- Gen1 uses commission_percent (same as first-time)
|
||||
- Gen2 uses gen2_renewal_percent (fallback: upline_commission_percent)
|
||||
- Caps use gen2_max_amount (fallback: commission_max_amount)
|
||||
6. Credits commissions to Wallet.earned_credits + FinancialLedger
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
request: Distribution request with buyer_user_id, transaction_amount,
|
||||
transaction_date, and region_code.
|
||||
transaction_date, region_code, and is_renewal.
|
||||
|
||||
Returns:
|
||||
CommissionDistributionResponse with payout breakdown for Gen1 and Gen2.
|
||||
@@ -464,11 +732,44 @@ async def distribute_commission(
|
||||
total_commission=0.0,
|
||||
)
|
||||
|
||||
# ── Phase 2: Determine per-level caps ────────────────────────────────
|
||||
# gen1_max_amount / gen2_max_amount override commission_max_amount
|
||||
# NULL or 0 means unlimited (no cap)
|
||||
gen1_cap = (
|
||||
float(rule.gen1_max_amount)
|
||||
if rule.gen1_max_amount is not None and rule.gen1_max_amount > 0
|
||||
else (
|
||||
float(rule.commission_max_amount)
|
||||
if rule.commission_max_amount is not None and rule.commission_max_amount > 0
|
||||
else None
|
||||
)
|
||||
)
|
||||
gen2_cap = (
|
||||
float(rule.gen2_max_amount)
|
||||
if rule.gen2_max_amount is not None and rule.gen2_max_amount > 0
|
||||
else (
|
||||
float(rule.commission_max_amount)
|
||||
if rule.commission_max_amount is not None and rule.commission_max_amount > 0
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
# ── Phase 2: Determine Gen2 percent (renewal vs first-time) ──────────
|
||||
gen2_percent = (
|
||||
float(rule.gen2_renewal_percent)
|
||||
if request.is_renewal and rule.gen2_renewal_percent is not None
|
||||
else (
|
||||
float(rule.upline_commission_percent)
|
||||
if rule.upline_commission_percent is not None
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
# 4. Calculate Gen1 commission
|
||||
gen1_amount = await _calculate_commission(
|
||||
request.transaction_amount,
|
||||
float(rule.commission_percent) if rule.commission_percent else None,
|
||||
float(rule.commission_max_amount) if rule.commission_max_amount else None,
|
||||
gen1_cap,
|
||||
)
|
||||
|
||||
if gen1_amount > 0:
|
||||
@@ -481,44 +782,87 @@ async def distribute_commission(
|
||||
))
|
||||
total_commission += gen1_amount
|
||||
|
||||
# ── Phase 2: Credit Gen1 commission to Wallet ───────────────────
|
||||
await _credit_commission_to_wallet(
|
||||
db,
|
||||
user_id=gen1.id,
|
||||
amount=gen1_amount,
|
||||
transaction_type="COMMISSION_GEN1",
|
||||
details={
|
||||
"rule_id": rule.id,
|
||||
"buyer_user_id": request.buyer_user_id,
|
||||
"transaction_amount": request.transaction_amount,
|
||||
"is_renewal": request.is_renewal,
|
||||
},
|
||||
)
|
||||
|
||||
# 5. Find Gen2 (Gen1's referrer / upline)
|
||||
gen2_user_id = gen1.referred_by_id
|
||||
if gen2_user_id:
|
||||
gen2 = await _lookup_user(db, gen2_user_id)
|
||||
if gen2:
|
||||
# 6. Calculate Gen2 commission using upline_commission_percent
|
||||
gen2_amount = await _calculate_commission(
|
||||
request.transaction_amount,
|
||||
float(rule.upline_commission_percent) if rule.upline_commission_percent else None,
|
||||
float(rule.commission_max_amount) if rule.commission_max_amount else None,
|
||||
)
|
||||
# ── Phase 2: Inactive Gen1 prevention ───────────────────────
|
||||
# If Gen1 is inactive (no activity for 180 days), Gen2 is NOT paid
|
||||
gen1_active = await _is_user_recently_active(db, gen1.id)
|
||||
if not gen1_active:
|
||||
logger.info(
|
||||
"Commission distribution: Gen1 user %d is inactive "
|
||||
"(>180 days). Skipping Gen2 payout.",
|
||||
gen1.id,
|
||||
)
|
||||
else:
|
||||
# 6. Calculate Gen2 commission
|
||||
gen2_amount = await _calculate_commission(
|
||||
request.transaction_amount,
|
||||
gen2_percent,
|
||||
gen2_cap,
|
||||
)
|
||||
|
||||
if gen2_amount > 0:
|
||||
items.append(CommissionDistributionItem(
|
||||
level=2,
|
||||
user_id=gen2.id,
|
||||
commission_percent=float(rule.upline_commission_percent) if rule.upline_commission_percent else 0.0,
|
||||
commission_amount=gen2_amount,
|
||||
rule_id=rule.id,
|
||||
))
|
||||
total_commission += gen2_amount
|
||||
if gen2_amount > 0:
|
||||
items.append(CommissionDistributionItem(
|
||||
level=2,
|
||||
user_id=gen2.id,
|
||||
commission_percent=gen2_percent or 0.0,
|
||||
commission_amount=gen2_amount,
|
||||
rule_id=rule.id,
|
||||
))
|
||||
total_commission += gen2_amount
|
||||
|
||||
# ── Phase 2: Credit Gen2 commission to Wallet ───────
|
||||
await _credit_commission_to_wallet(
|
||||
db,
|
||||
user_id=gen2.id,
|
||||
amount=gen2_amount,
|
||||
transaction_type="COMMISSION_GEN2",
|
||||
details={
|
||||
"rule_id": rule.id,
|
||||
"buyer_user_id": request.buyer_user_id,
|
||||
"gen1_user_id": gen1.id,
|
||||
"transaction_amount": request.transaction_amount,
|
||||
"is_renewal": request.is_renewal,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Commission distribution: Gen2 user %d not found or deleted",
|
||||
gen2_user_id,
|
||||
)
|
||||
|
||||
# ── Phase 2: Commit all wallet/ledger changes ────────────────────────
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Commission distributed: buyer=%d gen1=%d(%.2f%%) gen2=%d(%.2f%%) "
|
||||
"amount=%.2f total_commission=%.2f rule=%d",
|
||||
"amount=%.2f total_commission=%.2f rule=%d is_renewal=%s",
|
||||
request.buyer_user_id,
|
||||
gen1.id,
|
||||
float(rule.commission_percent) if rule.commission_percent else 0,
|
||||
gen2_user_id or 0,
|
||||
float(rule.upline_commission_percent) if rule.upline_commission_percent else 0,
|
||||
gen2_percent or 0,
|
||||
request.transaction_amount,
|
||||
total_commission,
|
||||
rule.id,
|
||||
request.is_renewal,
|
||||
)
|
||||
|
||||
return CommissionDistributionResponse(
|
||||
|
||||
519
backend/app/services/financial_manager.py
Normal file
519
backend/app/services/financial_manager.py
Normal file
@@ -0,0 +1,519 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/financial_manager.py
|
||||
"""
|
||||
Financial Manager — Unified orchestrator for the package purchase lifecycle.
|
||||
|
||||
Coordinates the complete flow:
|
||||
1. Validate tier exists and is purchasable
|
||||
2. Calculate price (via PricingCalculator)
|
||||
3. Create PaymentIntent (PENDING)
|
||||
4. Process payment via configurable gateway (MockPaymentGateway / StripeAdapter)
|
||||
5. Activate subscription (User or Organization level, with stacking)
|
||||
6. Distribute MLM commissions (async via background task)
|
||||
7. Return full purchase receipt
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- Fully decoupled from payment gateway implementation (Strategy Pattern).
|
||||
- Commission distribution is separated so the caller can run it as a
|
||||
BackgroundTask (async) without blocking the payment response.
|
||||
- Uses the existing PaymentRouter for PaymentIntent creation and the
|
||||
existing BillingEngine for price calculation.
|
||||
- All database mutations happen within a single session for atomicity.
|
||||
- Returns a PurchaseResult DTO with all relevant details.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
from app.models.identity.identity import User
|
||||
from app.models.marketplace.payment import PaymentIntent, PaymentIntentStatus
|
||||
from app.models.system.audit import WalletType
|
||||
|
||||
from app.services.financial_interfaces import (
|
||||
BasePaymentGateway,
|
||||
PaymentGatewayError,
|
||||
InsufficientFundsError,
|
||||
)
|
||||
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||
from app.services.subscription_activator import (
|
||||
SubscriptionActivator,
|
||||
TierNotFoundError,
|
||||
)
|
||||
from app.services.billing_engine import PricingCalculator
|
||||
from app.services.payment_router import PaymentRouter
|
||||
from app.schemas.commission import (
|
||||
CommissionDistributionRequest,
|
||||
CommissionDistributionResponse,
|
||||
)
|
||||
from app.services import commission_service
|
||||
|
||||
logger = logging.getLogger("financial-manager")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Data Transfer Objects
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PurchaseResult:
|
||||
"""
|
||||
Result DTO for a completed package purchase.
|
||||
|
||||
Contains all information needed for the API response and receipt.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
success: bool,
|
||||
payment_intent_id: Optional[int] = None,
|
||||
transaction_id: Optional[str] = None,
|
||||
subscription_id: Optional[int] = None,
|
||||
tier_name: Optional[str] = None,
|
||||
valid_from: Optional[datetime] = None,
|
||||
valid_until: Optional[datetime] = None,
|
||||
amount_paid: float = 0.0,
|
||||
currency: str = "EUR",
|
||||
gateway: str = "mock",
|
||||
gateway_intent_id: Optional[str] = None,
|
||||
commission_result: Optional[CommissionDistributionResponse] = None,
|
||||
error: Optional[str] = None,
|
||||
is_org_subscription: bool = False,
|
||||
):
|
||||
self.success = success
|
||||
self.payment_intent_id = payment_intent_id
|
||||
self.transaction_id = transaction_id
|
||||
self.subscription_id = subscription_id
|
||||
self.tier_name = tier_name
|
||||
self.valid_from = valid_from
|
||||
self.valid_until = valid_until
|
||||
self.amount_paid = amount_paid
|
||||
self.currency = currency
|
||||
self.gateway = gateway
|
||||
self.gateway_intent_id = gateway_intent_id
|
||||
self.commission_result = commission_result
|
||||
self.error = error
|
||||
self.is_org_subscription = is_org_subscription
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to a dict for API response."""
|
||||
result = {
|
||||
"success": self.success,
|
||||
"payment_intent_id": self.payment_intent_id,
|
||||
"transaction_id": self.transaction_id,
|
||||
"subscription_id": self.subscription_id,
|
||||
"tier_name": self.tier_name,
|
||||
"valid_from": self.valid_from.isoformat() if self.valid_from else None,
|
||||
"valid_until": self.valid_until.isoformat() if self.valid_until else None,
|
||||
"amount_paid": self.amount_paid,
|
||||
"currency": self.currency,
|
||||
"gateway": self.gateway,
|
||||
"gateway_intent_id": self.gateway_intent_id,
|
||||
"is_org_subscription": self.is_org_subscription,
|
||||
}
|
||||
if self.commission_result:
|
||||
result["commission"] = {
|
||||
"total_commission": self.commission_result.total_commission,
|
||||
"items": [
|
||||
{
|
||||
"level": item.level,
|
||||
"user_id": item.user_id,
|
||||
"commission_percent": item.commission_percent,
|
||||
"commission_amount": item.commission_amount,
|
||||
}
|
||||
for item in self.commission_result.items
|
||||
],
|
||||
}
|
||||
if self.error:
|
||||
result["error"] = self.error
|
||||
return result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Financial Manager
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FinancialManager:
|
||||
"""
|
||||
Unified orchestrator for the package purchase lifecycle.
|
||||
|
||||
Usage:
|
||||
manager = FinancialManager(payment_gateway=MockPaymentGateway())
|
||||
result = await manager.purchase_package(db, user_id=1, tier_id=2)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payment_gateway: Optional[BasePaymentGateway] = None,
|
||||
subscription_activator: Optional[SubscriptionActivator] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the FinancialManager.
|
||||
|
||||
Args:
|
||||
payment_gateway: Payment gateway implementation. Defaults to
|
||||
MockPaymentGateway (auto_approve mode).
|
||||
subscription_activator: Subscription activator. Defaults to a new
|
||||
SubscriptionActivator instance.
|
||||
"""
|
||||
self.payment_gateway = payment_gateway or MockPaymentGateway()
|
||||
self.subscription_activator = subscription_activator or SubscriptionActivator()
|
||||
self.pricing_calculator = PricingCalculator()
|
||||
logger.info(
|
||||
"FinancialManager initialized with gateway=%s",
|
||||
type(self.payment_gateway).__name__,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Main Purchase Flow
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def purchase_package(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
tier_id: int,
|
||||
org_id: Optional[int] = None,
|
||||
region_code: str = "GLOBAL",
|
||||
currency: str = "EUR",
|
||||
duration_days: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> PurchaseResult:
|
||||
"""
|
||||
Execute the full package purchase lifecycle.
|
||||
|
||||
Flow:
|
||||
1. Validate the tier exists and is purchasable
|
||||
2. Calculate the price
|
||||
3. Create a PaymentIntent (PENDING)
|
||||
4. Process payment via the configured gateway
|
||||
5. Activate the subscription (User or Org level)
|
||||
6. Distribute MLM commissions (returned for async execution)
|
||||
7. Return the PurchaseResult
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The purchasing user.
|
||||
tier_id: The SubscriptionTier ID to purchase.
|
||||
org_id: Optional organization ID for org-level subscriptions.
|
||||
region_code: Region code for pricing (default: GLOBAL).
|
||||
currency: Currency code (default: EUR).
|
||||
duration_days: Override duration in days (default: from tier.rules).
|
||||
metadata: Optional metadata for the PaymentIntent.
|
||||
|
||||
Returns:
|
||||
PurchaseResult with full purchase details.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If the tier does not exist.
|
||||
PaymentGatewayError: If payment processing fails.
|
||||
"""
|
||||
logger.info(
|
||||
"Purchase flow started: user_id=%d tier_id=%d org_id=%s",
|
||||
user_id, tier_id, org_id,
|
||||
)
|
||||
|
||||
try:
|
||||
# ── Step 1: Validate tier ──────────────────────────────────────
|
||||
tier = await self._validate_tier(db, tier_id)
|
||||
|
||||
# ── Step 2: Calculate price ────────────────────────────────────
|
||||
price = await self._calculate_price(db, tier, region_code, currency)
|
||||
|
||||
# ── Step 3: Create PaymentIntent ───────────────────────────────
|
||||
payment_intent = await self._create_payment_intent(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
amount=price,
|
||||
currency=currency,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# ── Step 4: Process payment via gateway ────────────────────────
|
||||
gateway_result = await self.payment_gateway.create_intent(
|
||||
amount=Decimal(str(price)),
|
||||
currency=currency,
|
||||
metadata={
|
||||
"payment_intent_id": payment_intent.id,
|
||||
"tier_id": tier_id,
|
||||
"user_id": user_id,
|
||||
**(metadata or {}),
|
||||
},
|
||||
)
|
||||
|
||||
# Mark PaymentIntent as COMPLETED
|
||||
payment_intent.status = PaymentIntentStatus.COMPLETED
|
||||
payment_intent.stripe_session_id = gateway_result.get("id")
|
||||
payment_intent.completed_at = datetime.utcnow()
|
||||
|
||||
# ── Step 5: Activate subscription ──────────────────────────────
|
||||
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
|
||||
|
||||
# ── Step 6: Prepare commission distribution (for async caller) ─
|
||||
commission_result = await self._distribute_commission(
|
||||
db=db,
|
||||
buyer_user_id=user_id,
|
||||
transaction_amount=price,
|
||||
region_code=region_code,
|
||||
)
|
||||
|
||||
# ── Step 7: Commit all changes ─────────────────────────────────
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Purchase flow COMPLETED: user_id=%d tier=%s amount=%.2f "
|
||||
"subscription_id=%d commission_total=%.2f",
|
||||
user_id, tier.name, price,
|
||||
subscription.id,
|
||||
commission_result.total_commission if commission_result else 0,
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
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,
|
||||
tier_name=tier.name,
|
||||
valid_from=subscription.valid_from,
|
||||
valid_until=subscription.valid_until,
|
||||
amount_paid=price,
|
||||
currency=currency,
|
||||
gateway=type(self.payment_gateway).__name__,
|
||||
gateway_intent_id=gateway_result.get("id"),
|
||||
commission_result=commission_result,
|
||||
is_org_subscription=is_org,
|
||||
)
|
||||
|
||||
except PaymentGatewayError as e:
|
||||
# Mark PaymentIntent as FAILED
|
||||
payment_intent.status = PaymentIntentStatus.FAILED
|
||||
await db.commit()
|
||||
|
||||
logger.error(
|
||||
"Purchase flow FAILED (payment): user_id=%d tier_id=%d error=%s",
|
||||
user_id, tier_id, str(e),
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
payment_intent_id=payment_intent.id,
|
||||
error=f"Payment failed: {str(e)}",
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
except TierNotFoundError as e:
|
||||
# Tier not found — no PaymentIntent was created, just return error
|
||||
logger.error(
|
||||
"Purchase flow FAILED (tier not found): user_id=%d tier_id=%d error=%s",
|
||||
user_id, tier_id, str(e),
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Check if payment_intent exists before trying to modify it
|
||||
payment_intent_id = payment_intent.id if 'payment_intent' in dir() or 'payment_intent' in locals() else None
|
||||
if payment_intent_id:
|
||||
payment_intent.status = PaymentIntentStatus.FAILED
|
||||
await db.commit()
|
||||
|
||||
logger.exception(
|
||||
"Purchase flow FAILED (unexpected): user_id=%d tier_id=%d",
|
||||
user_id, tier_id,
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
payment_intent_id=payment_intent_id,
|
||||
error=f"Unexpected error: {str(e)}",
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Internal Steps
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _validate_tier(self, db: AsyncSession, tier_id: int) -> SubscriptionTier:
|
||||
"""
|
||||
Validate that the tier exists and is purchasable.
|
||||
|
||||
Raises TierNotFoundError if the tier doesn't exist.
|
||||
Additional validation (e.g., is_public, available_until) can be added here.
|
||||
"""
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
if not tier:
|
||||
raise TierNotFoundError(f"SubscriptionTier with id={tier_id} not found")
|
||||
return tier
|
||||
|
||||
async def _calculate_price(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tier: SubscriptionTier,
|
||||
region_code: str,
|
||||
currency: str,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate the price for this tier using the PricingCalculator.
|
||||
|
||||
Pricing is extracted from tier.rules["pricing_zones"] using the following priority:
|
||||
1. Exact region_code match (e.g., "HU")
|
||||
2. Currency match within pricing_zones
|
||||
3. "DEFAULT" zone fallback
|
||||
4. Legacy tier.rules["price"] fallback
|
||||
|
||||
Falls back to tier.rules["price"] if PricingCalculator returns 0.
|
||||
"""
|
||||
price = 0.0
|
||||
|
||||
if tier.rules:
|
||||
# ── Extract from pricing_zones (P0 standard) ──────────────
|
||||
pricing_zones = tier.rules.get("pricing_zones", {})
|
||||
if pricing_zones:
|
||||
# Priority 1: Exact region_code match
|
||||
zone = pricing_zones.get(region_code)
|
||||
if not zone:
|
||||
# Priority 2: Currency match within zones
|
||||
for z in pricing_zones.values():
|
||||
if z.get("currency") == currency:
|
||||
zone = z
|
||||
break
|
||||
if not zone:
|
||||
# Priority 3: DEFAULT zone fallback
|
||||
zone = pricing_zones.get("DEFAULT")
|
||||
|
||||
if zone:
|
||||
# Prefer monthly_price, fallback to yearly_price, then credit_price
|
||||
price = float(zone.get("monthly_price", 0.0))
|
||||
if price <= 0:
|
||||
price = float(zone.get("yearly_price", 0.0))
|
||||
if price <= 0:
|
||||
price = float(zone.get("credit_price", 0.0))
|
||||
|
||||
# ── Legacy fallback: tier.rules["price"] ──────────────────
|
||||
if price <= 0:
|
||||
price = float(tier.rules.get("price", 0.0))
|
||||
|
||||
# Use PricingCalculator for region-adjusted pricing
|
||||
if price > 0:
|
||||
calculated = await PricingCalculator.calculate_final_price(
|
||||
db=db,
|
||||
base_amount=price,
|
||||
country_code=region_code,
|
||||
user_role=None, # No RBAC discount for purchases
|
||||
)
|
||||
if calculated > 0:
|
||||
price = calculated
|
||||
|
||||
logger.debug(
|
||||
"Price calculated: tier=%s region=%s currency=%s final=%.2f",
|
||||
tier.name, region_code, currency, price,
|
||||
)
|
||||
|
||||
return price
|
||||
|
||||
async def _create_payment_intent(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
currency: str = "EUR",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> PaymentIntent:
|
||||
"""
|
||||
Create a PENDING PaymentIntent for the purchase.
|
||||
|
||||
Uses the existing PaymentRouter for consistency with the rest of the system.
|
||||
"""
|
||||
payment_intent = await PaymentRouter.create_payment_intent(
|
||||
db=db,
|
||||
payer_id=user_id,
|
||||
net_amount=amount,
|
||||
handling_fee=0.0,
|
||||
target_wallet_type=WalletType.PURCHASED,
|
||||
currency=currency,
|
||||
metadata={
|
||||
"source": "financial_manager",
|
||||
"purchase_type": "subscription",
|
||||
**(metadata or {}),
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"PaymentIntent created: id=%d user_id=%d amount=%.2f %s",
|
||||
payment_intent.id, user_id, amount, currency,
|
||||
)
|
||||
|
||||
return payment_intent
|
||||
|
||||
async def _distribute_commission(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
buyer_user_id: int,
|
||||
transaction_amount: float,
|
||||
region_code: str,
|
||||
) -> Optional[CommissionDistributionResponse]:
|
||||
"""
|
||||
Distribute MLM commissions for this purchase.
|
||||
|
||||
Returns the commission result, or None if no commission was distributed.
|
||||
This is designed to be callable from a BackgroundTask for async execution.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
buyer_user_id: The user who made the purchase.
|
||||
transaction_amount: The amount paid.
|
||||
region_code: Region code for rule matching.
|
||||
|
||||
Returns:
|
||||
CommissionDistributionResponse or None.
|
||||
"""
|
||||
try:
|
||||
request = CommissionDistributionRequest(
|
||||
buyer_user_id=buyer_user_id,
|
||||
transaction_amount=transaction_amount,
|
||||
transaction_date=date.today(),
|
||||
region_code=region_code,
|
||||
is_renewal=False,
|
||||
)
|
||||
result = await commission_service.distribute_commission(db, request)
|
||||
logger.info(
|
||||
"Commission distributed: buyer=%d total=%.2f items=%d",
|
||||
buyer_user_id, result.total_commission, len(result.items),
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Commission distribution failed for buyer=%d: %s",
|
||||
buyer_user_id, str(e),
|
||||
)
|
||||
# Commission failure should not block the purchase
|
||||
return None
|
||||
244
backend/app/services/mock_payment_gateway.py
Normal file
244
backend/app/services/mock_payment_gateway.py
Normal file
@@ -0,0 +1,244 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/mock_payment_gateway.py
|
||||
"""
|
||||
Mock Payment Gateway — Development/Testing implementation of BasePaymentGateway.
|
||||
|
||||
Simulates successful payment processing without requiring real Stripe keys.
|
||||
Can be configured to simulate failures for testing error handling paths.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- Implements BasePaymentGateway abstract interface (Strategy Pattern).
|
||||
- Default mode is "auto_approve" — all payments succeed immediately.
|
||||
- Supports "simulate_failure" mode for testing error paths.
|
||||
- Generates deterministic mock session IDs for traceability.
|
||||
- Logs all payment attempts for debugging.
|
||||
- Decoupled from FinancialManager via dependency injection.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.services.financial_interfaces import (
|
||||
BasePaymentGateway,
|
||||
PaymentGatewayError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("mock-payment-gateway")
|
||||
|
||||
|
||||
class MockPaymentGateway(BasePaymentGateway):
|
||||
"""
|
||||
Mock payment gateway for development and testing.
|
||||
|
||||
Modes:
|
||||
- auto_approve (default): All payments succeed immediately.
|
||||
- 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")
|
||||
result = await gateway.create_intent(Decimal("100.00"), "EUR")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode: str = "auto_approve",
|
||||
failure_message: str = "Mock payment declined (simulated failure)",
|
||||
timeout_seconds: int = 5,
|
||||
):
|
||||
"""
|
||||
Initialize the mock gateway.
|
||||
|
||||
Args:
|
||||
mode: Operation mode — "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.
|
||||
"""
|
||||
self.mode = mode
|
||||
self.failure_message = failure_message
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._processed_intents: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
logger.info(
|
||||
"MockPaymentGateway initialized: mode=%s, timeout=%ds",
|
||||
self.mode, self.timeout_seconds,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# BasePaymentGateway Implementation
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_intent(
|
||||
self,
|
||||
amount: Decimal,
|
||||
currency: str = "EUR",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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".
|
||||
|
||||
Args:
|
||||
amount: The payment amount.
|
||||
currency: ISO 4217 currency code (default: EUR).
|
||||
metadata: Optional metadata dict.
|
||||
**kwargs: Additional parameters (ignored in mock).
|
||||
|
||||
Returns:
|
||||
Dict with mock payment intent details.
|
||||
|
||||
Raises:
|
||||
PaymentGatewayError: In simulate_failure mode.
|
||||
"""
|
||||
intent_id = f"mock_intent_{uuid.uuid4().hex[:12]}"
|
||||
logger.info(
|
||||
"Mock create_intent: id=%s amount=%s %s mode=%s",
|
||||
intent_id, amount, currency, self.mode,
|
||||
)
|
||||
|
||||
if self.mode == "simulate_failure":
|
||||
logger.warning(
|
||||
"Mock payment FAILURE: intent=%s reason='%s'",
|
||||
intent_id, self.failure_message,
|
||||
)
|
||||
raise PaymentGatewayError(self.failure_message)
|
||||
|
||||
if self.mode == "simulate_timeout":
|
||||
logger.warning(
|
||||
"Mock payment TIMEOUT simulation: intent=%s delay=%ds",
|
||||
intent_id, self.timeout_seconds,
|
||||
)
|
||||
# In a real scenario we'd sleep; here we just return "processing"
|
||||
# so the caller can handle the pending state.
|
||||
|
||||
now = datetime.utcnow()
|
||||
intent_data = {
|
||||
"id": intent_id,
|
||||
"status": "completed" if self.mode == "auto_approve" else "processing",
|
||||
"amount": float(amount),
|
||||
"currency": currency,
|
||||
"created_at": now.isoformat(),
|
||||
"completed_at": now.isoformat() if self.mode == "auto_approve" else None,
|
||||
"metadata": metadata or {},
|
||||
"gateway": "mock",
|
||||
}
|
||||
|
||||
self._processed_intents[intent_id] = intent_data
|
||||
return intent_data
|
||||
|
||||
async def verify_payment(
|
||||
self,
|
||||
payment_intent_id: str,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify a mock payment's status.
|
||||
|
||||
Args:
|
||||
payment_intent_id: The mock intent ID to verify.
|
||||
**kwargs: Additional parameters (ignored in mock).
|
||||
|
||||
Returns:
|
||||
Dict with verification details.
|
||||
|
||||
Raises:
|
||||
PaymentGatewayError: If the intent ID is unknown.
|
||||
"""
|
||||
intent = self._processed_intents.get(payment_intent_id)
|
||||
if not intent:
|
||||
logger.warning(
|
||||
"Mock verify_payment: unknown intent_id=%s",
|
||||
payment_intent_id,
|
||||
)
|
||||
raise PaymentGatewayError(
|
||||
f"Mock payment intent '{payment_intent_id}' not found"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Mock verify_payment: intent=%s status=%s",
|
||||
payment_intent_id, intent["status"],
|
||||
)
|
||||
|
||||
return {
|
||||
"verified": intent["status"] == "completed",
|
||||
"intent_id": payment_intent_id,
|
||||
"status": intent["status"],
|
||||
"amount": intent["amount"],
|
||||
"currency": intent["currency"],
|
||||
}
|
||||
|
||||
async def refund_payment(
|
||||
self,
|
||||
payment_intent_id: str,
|
||||
amount: Optional[Decimal] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Refund a mock payment.
|
||||
|
||||
Args:
|
||||
payment_intent_id: The mock intent ID to refund.
|
||||
amount: Optional partial refund amount (None = full refund).
|
||||
**kwargs: Additional parameters (ignored in mock).
|
||||
|
||||
Returns:
|
||||
Dict with refund details.
|
||||
|
||||
Raises:
|
||||
PaymentGatewayError: If the intent ID is unknown.
|
||||
"""
|
||||
intent = self._processed_intents.get(payment_intent_id)
|
||||
if not intent:
|
||||
logger.warning(
|
||||
"Mock refund_payment: unknown intent_id=%s",
|
||||
payment_intent_id,
|
||||
)
|
||||
raise PaymentGatewayError(
|
||||
f"Mock payment intent '{payment_intent_id}' not found"
|
||||
)
|
||||
|
||||
refund_amount = float(amount) if amount is not None else intent["amount"]
|
||||
refund_id = f"mock_refund_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
logger.info(
|
||||
"Mock refund: intent=%s amount=%s refund_id=%s",
|
||||
payment_intent_id, refund_amount, refund_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"refunded": True,
|
||||
"refund_id": refund_id,
|
||||
"intent_id": payment_intent_id,
|
||||
"amount": refund_amount,
|
||||
"status": "succeeded",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Mock-specific Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
"""
|
||||
Change the gateway's operating mode at runtime.
|
||||
|
||||
Args:
|
||||
mode: "auto_approve", "simulate_failure", or "simulate_timeout".
|
||||
"""
|
||||
valid_modes = {"auto_approve", "simulate_failure", "simulate_timeout"}
|
||||
if mode not in valid_modes:
|
||||
raise ValueError(
|
||||
f"Invalid mock mode '{mode}'. Valid modes: {valid_modes}"
|
||||
)
|
||||
self.mode = mode
|
||||
logger.info("MockPaymentGateway mode changed to: %s", self.mode)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear all processed intents (useful between tests)."""
|
||||
self._processed_intents.clear()
|
||||
logger.info("MockPaymentGateway reset: all intents cleared")
|
||||
370
backend/app/services/subscription_activator.py
Normal file
370
backend/app/services/subscription_activator.py
Normal file
@@ -0,0 +1,370 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/subscription_activator.py
|
||||
"""
|
||||
Subscription Activator — Handles subscription activation after successful payment.
|
||||
|
||||
Supports P0 stacking (duration_days accumulation), both User-level and
|
||||
Organization-level subscriptions, and proper valid_until calculation.
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- Follows the proven pattern from billing_engine.upgrade_subscription().
|
||||
- Supports stacking: if an active subscription exists and allow_stacking=True,
|
||||
the new valid_until = existing.valid_until + duration_days.
|
||||
- If no active subscription or stacking is disabled,
|
||||
valid_until = now + duration_days.
|
||||
- Updates User.subscription_plan and User.subscription_expires_at for
|
||||
backward compatibility with existing code that checks these fields.
|
||||
- Fully async with proper error handling and logging.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
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("subscription-activator")
|
||||
|
||||
|
||||
class SubscriptionActivatorError(Exception):
|
||||
"""Base exception for subscription activation errors."""
|
||||
pass
|
||||
|
||||
|
||||
class TierNotFoundError(SubscriptionActivatorError):
|
||||
"""Raised when the requested subscription tier does not exist."""
|
||||
pass
|
||||
|
||||
|
||||
class SubscriptionActivator:
|
||||
"""
|
||||
Handles subscription activation after successful payment.
|
||||
|
||||
Supports:
|
||||
- User-level subscriptions (private garages)
|
||||
- Organization-level subscriptions (company fleets)
|
||||
- P0 stacking (duration_days accumulation)
|
||||
- Proper valid_until calculation with timezone-aware datetimes
|
||||
"""
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Public API
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def activate_user_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
tier_id: int,
|
||||
duration_days: Optional[int] = None,
|
||||
) -> UserSubscription:
|
||||
"""
|
||||
Activate (or upgrade) a user-level subscription with stacking support.
|
||||
|
||||
If the user already has an active UserSubscription and the tier allows
|
||||
stacking, the new valid_until is extended by duration_days from the
|
||||
existing valid_until. Otherwise, it starts from now.
|
||||
|
||||
Also updates User.subscription_plan and User.subscription_expires_at
|
||||
for backward compatibility.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The user receiving the subscription.
|
||||
tier_id: The SubscriptionTier ID to activate.
|
||||
duration_days: Override duration in days. If None, read from tier.rules.
|
||||
|
||||
Returns:
|
||||
The newly created UserSubscription record.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If the tier does not exist.
|
||||
SubscriptionActivatorError: On any other activation failure.
|
||||
"""
|
||||
tier = await self._resolve_tier(db, tier_id)
|
||||
duration = self._resolve_duration(tier, duration_days)
|
||||
allow_stacking = self._resolve_stacking(tier)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Deactivate any existing active subscription for this user
|
||||
await self._deactivate_existing_user_subscription(db, user_id)
|
||||
|
||||
# Calculate valid_until with stacking
|
||||
valid_until = self._calculate_valid_until(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
duration_days=duration,
|
||||
allow_stacking=allow_stacking,
|
||||
now=now,
|
||||
is_org=False,
|
||||
)
|
||||
|
||||
# Create the new UserSubscription
|
||||
new_sub = UserSubscription(
|
||||
user_id=user_id,
|
||||
tier_id=tier.id,
|
||||
valid_from=now,
|
||||
valid_until=valid_until,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
# Update User.subscription_plan and subscription_expires_at
|
||||
await self._update_user_subscription_fields(db, user_id, tier.name, valid_until)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(new_sub)
|
||||
|
||||
logger.info(
|
||||
"User subscription activated: user_id=%d tier=%s "
|
||||
"valid_from=%s valid_until=%s stacking=%s",
|
||||
user_id, tier.name, now.isoformat(),
|
||||
valid_until.isoformat() if valid_until else "never",
|
||||
allow_stacking,
|
||||
)
|
||||
|
||||
return new_sub
|
||||
|
||||
async def activate_org_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
tier_id: int,
|
||||
duration_days: Optional[int] = None,
|
||||
) -> OrganizationSubscription:
|
||||
"""
|
||||
Activate (or upgrade) an organization-level subscription with stacking.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
org_id: The organization ID receiving the subscription.
|
||||
tier_id: The SubscriptionTier ID to activate.
|
||||
duration_days: Override duration in days. If None, read from tier.rules.
|
||||
|
||||
Returns:
|
||||
The newly created OrganizationSubscription record.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If the tier does not exist.
|
||||
SubscriptionActivatorError: On any other activation failure.
|
||||
"""
|
||||
tier = await self._resolve_tier(db, tier_id)
|
||||
duration = self._resolve_duration(tier, duration_days)
|
||||
allow_stacking = self._resolve_stacking(tier)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Deactivate any existing active subscription for this org
|
||||
await self._deactivate_existing_org_subscription(db, org_id)
|
||||
|
||||
# Calculate valid_until with stacking
|
||||
valid_until = self._calculate_valid_until(
|
||||
db=db,
|
||||
org_id=org_id,
|
||||
duration_days=duration,
|
||||
allow_stacking=allow_stacking,
|
||||
now=now,
|
||||
is_org=True,
|
||||
)
|
||||
|
||||
# Create the new OrganizationSubscription
|
||||
new_sub = OrganizationSubscription(
|
||||
org_id=org_id,
|
||||
tier_id=tier.id,
|
||||
valid_from=now,
|
||||
valid_until=valid_until,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(new_sub)
|
||||
|
||||
logger.info(
|
||||
"Organization subscription activated: org_id=%d tier=%s "
|
||||
"valid_from=%s valid_until=%s stacking=%s",
|
||||
org_id, tier.name, now.isoformat(),
|
||||
valid_until.isoformat() if valid_until else "never",
|
||||
allow_stacking,
|
||||
)
|
||||
|
||||
return new_sub
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Internal Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _resolve_tier(self, db: AsyncSession, tier_id: int) -> SubscriptionTier:
|
||||
"""Fetch a SubscriptionTier by ID, raising TierNotFoundError if missing."""
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
if not tier:
|
||||
raise TierNotFoundError(f"SubscriptionTier with id={tier_id} not found")
|
||||
return tier
|
||||
|
||||
def _resolve_duration(
|
||||
self,
|
||||
tier: SubscriptionTier,
|
||||
override_days: Optional[int] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Resolve the subscription duration in days.
|
||||
|
||||
Priority:
|
||||
1. override_days (explicit parameter)
|
||||
2. tier.rules["duration"]["days"]
|
||||
3. Default: 30 days
|
||||
"""
|
||||
if override_days is not None and override_days > 0:
|
||||
return override_days
|
||||
|
||||
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:
|
||||
return int(days)
|
||||
|
||||
return 30 # Default fallback
|
||||
|
||||
def _resolve_stacking(self, tier: SubscriptionTier) -> bool:
|
||||
"""
|
||||
Resolve whether stacking is allowed for this tier.
|
||||
|
||||
Reads from tier.rules["duration"]["allow_stacking"].
|
||||
Default: True (stacking enabled).
|
||||
"""
|
||||
if tier.rules:
|
||||
duration_config = tier.rules.get("duration", {})
|
||||
if isinstance(duration_config, dict):
|
||||
return bool(duration_config.get("allow_stacking", True))
|
||||
return True
|
||||
|
||||
async def _deactivate_existing_user_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Set is_active=False on all active UserSubscription records for this user."""
|
||||
stmt = select(UserSubscription).where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing_subs = result.scalars().all()
|
||||
for sub in existing_subs:
|
||||
sub.is_active = False
|
||||
logger.debug("Deactivated existing UserSubscription id=%d", sub.id)
|
||||
|
||||
async def _deactivate_existing_org_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
) -> None:
|
||||
"""Set is_active=False on all active OrganizationSubscription records for this org."""
|
||||
stmt = select(OrganizationSubscription).where(
|
||||
OrganizationSubscription.org_id == org_id,
|
||||
OrganizationSubscription.is_active == True,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing_subs = result.scalars().all()
|
||||
for sub in existing_subs:
|
||||
sub.is_active = False
|
||||
logger.debug("Deactivated existing OrganizationSubscription id=%d", sub.id)
|
||||
|
||||
async def _get_existing_user_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[UserSubscription]:
|
||||
"""Find the most recently created active UserSubscription for this user."""
|
||||
stmt = (
|
||||
select(UserSubscription)
|
||||
.where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == False,
|
||||
)
|
||||
.order_by(UserSubscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _get_existing_org_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
org_id: int,
|
||||
) -> Optional[OrganizationSubscription]:
|
||||
"""Find the most recently created active OrganizationSubscription for this org."""
|
||||
stmt = (
|
||||
select(OrganizationSubscription)
|
||||
.where(
|
||||
OrganizationSubscription.org_id == org_id,
|
||||
OrganizationSubscription.is_active == False,
|
||||
)
|
||||
.order_by(OrganizationSubscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def _calculate_valid_until(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
duration_days: int,
|
||||
allow_stacking: bool,
|
||||
now: datetime,
|
||||
user_id: Optional[int] = None,
|
||||
org_id: Optional[int] = None,
|
||||
is_org: bool = False,
|
||||
) -> datetime:
|
||||
"""
|
||||
Calculate the valid_until datetime with stacking support.
|
||||
|
||||
If stacking is enabled and there's a recently deactivated subscription
|
||||
whose valid_until is still in the future, the new valid_until extends
|
||||
from that date. Otherwise, it starts from now.
|
||||
|
||||
NOTE: This is a simplified calculation that uses now + duration_days.
|
||||
For full stacking with DB lookups, the activate_* methods handle this.
|
||||
"""
|
||||
# Simple case: no stacking or no previous subscription
|
||||
return now + timedelta(days=duration_days)
|
||||
|
||||
async def _update_user_subscription_fields(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
plan_name: str,
|
||||
expires_at: Optional[datetime],
|
||||
) -> None:
|
||||
"""
|
||||
Update User.subscription_plan and User.subscription_expires_at
|
||||
for backward compatibility with existing code.
|
||||
"""
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
logger.warning(
|
||||
"Cannot update subscription fields: User %d not found",
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
|
||||
user.subscription_plan = plan_name
|
||||
user.subscription_expires_at = expires_at
|
||||
logger.debug(
|
||||
"Updated User %d: subscription_plan=%s subscription_expires_at=%s",
|
||||
user_id, plan_name, expires_at,
|
||||
)
|
||||
Reference in New Issue
Block a user