Files
remnawave-minishop/backend/db/dal/payment_dal.py
T
3252a8 fbb89793cb fix: separate HWID device renewal flows
Keep one-off device top-ups scoped to the active subscription term and move device renewal into subscription checkout.

Carry HWID renewal metadata through provider callbacks and webhooks, including YooKassa saved-card flows.

Add admin extension controls, docs, demo data, and regression coverage.
2026-06-03 23:51:46 +03:00

361 lines
13 KiB
Python

import logging
from typing import Any, Dict, List, Optional
from sqlalchemy import Date, and_, case, cast, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import joinedload, selectinload
from db.models import Payment, User
async def create_payment_record(session: AsyncSession, payment_data: Dict[str, Any]) -> Payment:
from .user_dal import get_user_by_id
user = await get_user_by_id(session, payment_data["user_id"])
if not user:
raise ValueError(f"User with id {payment_data['user_id']} not found for creating payment.")
if payment_data.get("promo_code_id"):
from .promo_code_dal import get_promo_code_by_id
promo = await get_promo_code_by_id(session, payment_data["promo_code_id"])
if not promo:
raise ValueError(f"Promo code with id {payment_data['promo_code_id']} not found.")
new_payment = Payment(**payment_data)
session.add(new_payment)
await session.flush()
await session.refresh(new_payment)
logging.info(f"Payment record {new_payment.payment_id} created for user {new_payment.user_id}")
return new_payment
async def get_payment_by_provider_payment_id(
session: AsyncSession, provider_payment_id: str
) -> Optional[Payment]:
"""Fetch a payment by provider-specific identifier."""
stmt = select(Payment).where(Payment.provider_payment_id == provider_payment_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def ensure_payment_with_provider_id(
session: AsyncSession,
*,
user_id: int,
amount: float,
currency: str,
months: int,
description: str,
provider: str,
provider_payment_id: str,
sale_mode: Optional[str] = None,
tariff_key: Optional[str] = None,
purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = None,
hwid_valid_from: Optional[Any] = None,
hwid_valid_until: Optional[Any] = None,
hwid_pricing_period_months: Optional[int] = None,
hwid_proration_ratio: Optional[float] = None,
hwid_full_price: Optional[float] = None,
) -> Payment:
"""Idempotently create a payment record for a provider event.
If a payment with the same provider_payment_id already exists, returns it.
Otherwise creates a new pending payment with provided data.
"""
existing = await get_payment_by_provider_payment_id(session, provider_payment_id)
if existing:
return existing
pending_status = f"pending_{provider}" if provider else "pending"
payment_payload: Dict[str, Any] = {
"user_id": user_id,
"amount": float(amount),
"currency": currency,
"status": pending_status,
"description": description,
"subscription_duration_months": months,
"provider_payment_id": provider_payment_id,
"provider": provider,
}
optional_fields = {
"sale_mode": sale_mode,
"tariff_key": tariff_key,
"purchased_gb": purchased_gb,
"purchased_hwid_devices": purchased_hwid_devices,
"hwid_valid_from": hwid_valid_from,
"hwid_valid_until": hwid_valid_until,
"hwid_pricing_period_months": hwid_pricing_period_months,
"hwid_proration_ratio": hwid_proration_ratio,
"hwid_full_price": hwid_full_price,
}
payment_payload.update(
{field: value for field, value in optional_fields.items() if value is not None}
)
return await create_payment_record(session, payment_payload)
async def get_payment_by_db_id(session: AsyncSession, payment_db_id: int) -> Optional[Payment]:
stmt = (
select(Payment)
.where(Payment.payment_id == payment_db_id)
.options(joinedload(Payment.user), joinedload(Payment.promo_code_used))
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def find_recent_pending_provider_payment(
session: AsyncSession,
*,
user_id: int,
provider: str,
pending_status: str,
amount: float,
sale_mode: Optional[str],
months: Optional[int],
purchased_gb: Optional[float],
purchased_hwid_devices: Optional[int],
tariff_key: Optional[str] = None,
since_minutes: int = 60,
) -> Optional[Payment]:
"""Return the most recent pending payment matching the given tariff parameters.
Used to reuse an existing provider payment link instead of creating a new one
on repeated user clicks. Only payments with a populated ``provider_payment_id``
are returned — without it, there's no link to reuse.
"""
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(minutes=max(1, since_minutes))
conditions = [
Payment.user_id == user_id,
Payment.provider == provider,
Payment.status == pending_status,
Payment.provider_payment_id.isnot(None),
Payment.created_at >= cutoff,
func.abs(Payment.amount - float(amount)) < 0.01,
]
if sale_mode is not None:
conditions.append(Payment.sale_mode == sale_mode)
if tariff_key is not None:
conditions.append(Payment.tariff_key == tariff_key)
if months is not None:
conditions.append(Payment.subscription_duration_months == months)
else:
conditions.append(Payment.subscription_duration_months.is_(None))
if purchased_gb is not None:
conditions.append(func.abs(Payment.purchased_gb - float(purchased_gb)) < 0.0001)
else:
conditions.append(Payment.purchased_gb.is_(None))
if purchased_hwid_devices is not None:
conditions.append(Payment.purchased_hwid_devices == purchased_hwid_devices)
else:
conditions.append(Payment.purchased_hwid_devices.is_(None))
stmt = (
select(Payment)
.where(and_(*conditions))
.options(joinedload(Payment.user), joinedload(Payment.promo_code_used))
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def update_payment_status_by_db_id(
session: AsyncSession, payment_db_id: int, new_status: str, yk_payment_id: Optional[str] = None
) -> Optional[Payment]:
payment = await get_payment_by_db_id(session, payment_db_id)
if payment:
payment.status = new_status
payment.updated_at = func.now()
if yk_payment_id and payment.yookassa_payment_id is None:
payment.yookassa_payment_id = yk_payment_id
await session.flush()
await session.refresh(payment)
logging.info(f"Payment record {payment.payment_id} status updated to {new_status}.")
else:
logging.warning(f"Payment record with DB ID {payment_db_id} not found for status update.")
return payment
async def get_recent_payment_logs_with_user(
session: AsyncSession, limit: int = 20, offset: int = 0
) -> List[Payment]:
stmt = (
select(Payment)
.options(joinedload(Payment.user))
.where(Payment.status == "succeeded")
.order_by(Payment.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await session.execute(stmt)
return result.scalars().all()
async def get_payments_count(session: AsyncSession) -> int:
"""Get total count of successful payments."""
stmt = select(func.count(Payment.payment_id)).where(Payment.status == "succeeded")
result = await session.execute(stmt)
return result.scalar() or 0
async def get_all_succeeded_payments_with_user(session: AsyncSession) -> List[Payment]:
"""Get all successful payments with user data for export."""
stmt = (
select(Payment)
.options(selectinload(Payment.user))
.where(Payment.status == "succeeded")
.order_by(Payment.created_at.desc())
)
result = await session.execute(stmt)
return result.scalars().all()
async def count_user_succeeded_payments(
session: AsyncSession, user_id: int, exclude_payment_id: Optional[int] = None
) -> int:
"""Count succeeded payments for a specific user.
If exclude_payment_id is provided, that specific payment will be excluded
from the count. Useful to check "prior" payments while processing the
current payment in the same transaction.
"""
conditions = [Payment.user_id == user_id, Payment.status == "succeeded"]
if exclude_payment_id is not None:
conditions.append(Payment.payment_id != exclude_payment_id)
stmt = select(func.count(Payment.payment_id)).where(and_(*conditions))
result = await session.execute(stmt)
return result.scalar() or 0
async def update_provider_payment_and_status(
session: AsyncSession, payment_db_id: int, provider_payment_id: str, new_status: str
) -> Optional[Payment]:
payment = await get_payment_by_db_id(session, payment_db_id)
if payment:
payment.status = new_status
payment.provider_payment_id = provider_payment_id
payment.updated_at = func.now()
await session.flush()
await session.refresh(payment)
logging.info(
f"Payment record {payment.payment_id} updated with provider id {provider_payment_id} and status {new_status}." # noqa: E501
)
else:
logging.warning(f"Payment record with DB ID {payment_db_id} not found for provider update.")
return payment
async def _daily_revenue_series_utc(session: AsyncSession, days: int = 14) -> List[Dict[str, Any]]:
"""Succeeded payment totals per calendar day (UTC) for the last `days` days."""
from datetime import date, datetime, timedelta, timezone
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
range_start = today_start - timedelta(days=days - 1)
day_col = cast(func.date_trunc("day", Payment.created_at), Date).label("d")
stmt = (
select(day_col, func.coalesce(func.sum(Payment.amount), 0.0))
.where(
and_(
Payment.status == "succeeded",
Payment.created_at >= range_start,
)
)
.group_by(day_col)
.order_by(day_col)
)
result = await session.execute(stmt)
by_day: Dict[date, float] = {}
for row in result.all():
d_key = row[0]
if isinstance(d_key, datetime):
d_key = d_key.date()
by_day[d_key] = float(row[1] or 0)
out: List[Dict[str, Any]] = []
for i in range(days):
d = (range_start + timedelta(days=i)).date()
out.append({"date": d.isoformat(), "amount": float(by_day.get(d, 0.0) or 0.0)})
return out
async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
"""Get comprehensive financial statistics."""
from datetime import datetime, timedelta
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=7)
month_start = today_start - timedelta(days=30)
revenue_stmt = select(
func.coalesce(
func.sum(case((Payment.created_at >= today_start, Payment.amount), else_=0)), 0
),
func.coalesce(
func.sum(case((Payment.created_at >= week_start, Payment.amount), else_=0)), 0
),
func.coalesce(
func.sum(case((Payment.created_at >= month_start, Payment.amount), else_=0)),
0,
),
func.coalesce(func.sum(Payment.amount), 0),
func.coalesce(func.sum(case((Payment.created_at >= today_start, 1), else_=0)), 0),
).where(Payment.status == "succeeded")
revenue_row = (await session.execute(revenue_stmt)).one()
today_amount = revenue_row[0] or 0
week_amount = revenue_row[1] or 0
month_amount = revenue_row[2] or 0
all_amount = revenue_row[3] or 0
today_payments_count = int(revenue_row[4] or 0)
# Longer tail for admin dashboard charts (presets up to 1y + custom range on the client).
daily_series = await _daily_revenue_series_utc(session, days=730)
return {
"today_revenue": float(today_amount),
"week_revenue": float(week_amount),
"month_revenue": float(month_amount),
"all_time_revenue": float(all_amount),
"today_payments_count": today_payments_count,
"daily_series": daily_series,
}
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
stmt = select(func.sum(Payment.amount)).where(
and_(Payment.user_id == user_id, Payment.status == "succeeded")
)
result = await session.execute(stmt)
total = result.scalar()
return float(total or 0)
async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float:
"""Get total revenue generated from referred users' payments.
This calculates the sum of all succeeded payments made by users
where referred_by_id equals the referrer_id.
"""
stmt = (
select(func.sum(Payment.amount))
.join(User, Payment.user_id == User.user_id)
.where(and_(User.referred_by_id == referrer_id, Payment.status == "succeeded"))
)
result = await session.execute(stmt)
total = result.scalar()
return float(total or 0)