Files
service-finder/backend/app/services/financial_manager.py

845 lines
36 KiB
Python

# /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
from app.services.subscription_service import SubscriptionService
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,
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
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.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."""
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,
}
# 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,
"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 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,
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,
"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(
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 (immediate): 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"),
payment_status="COMPLETED",
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
# ──────────────────────────────────────────────────────────────────────────
# 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,
)