Merge pull request #2792 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-03-21 07:39:51 +03:00
committed by GitHub
12 changed files with 262 additions and 21 deletions
+7 -7
View File
@@ -128,7 +128,7 @@ async def get_sales_summary(
# Manual top-ups by admins
manual_topup_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True,
@@ -246,7 +246,7 @@ async def get_sales_summary(
# Add-on revenue
addon_revenue_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
and_(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed == True,
@@ -256,7 +256,7 @@ async def get_sales_summary(
)
)
)
addon_revenue = abs(addon_revenue_result.scalar() or 0)
addon_revenue = addon_revenue_result.scalar() or 0
return SalesSummary(
total_revenue_kopeks=total_revenue + manual_topup,
@@ -1101,11 +1101,11 @@ async def get_deposits_stats(
select(
Transaction.payment_method.label('method'),
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
)
.where(base_filter)
.group_by(Transaction.payment_method)
.order_by(func.sum(Transaction.amount_kopeks).desc())
.order_by(func.sum(func.abs(Transaction.amount_kopeks)).desc())
)
by_method = [
DepositByMethodItem(method=row.method or 'unknown', count=row.count, amount_kopeks=row.amount)
@@ -1116,7 +1116,7 @@ async def get_deposits_stats(
select(
func.date(Transaction.created_at).label('date'),
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
)
.where(base_filter)
.group_by(func.date(Transaction.created_at))
@@ -1137,7 +1137,7 @@ async def get_deposits_stats(
select(
func.date(Transaction.created_at).label('date'),
Transaction.payment_method.label('method'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
)
.where(base_filter)
.group_by(func.date(Transaction.created_at), Transaction.payment_method)
+3
View File
@@ -29,6 +29,7 @@ from app.database.crud.user import (
from app.database.crud.user_promo_group import sync_user_primary_promo_group
from app.database.models import (
GuestPurchase,
PaymentMethod,
PromoGroup,
ReferralEarning,
Subscription,
@@ -897,6 +898,7 @@ async def update_user_balance(
description=request.description,
create_transaction=request.create_transaction,
transaction_type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.MANUAL,
)
else:
# Subtract balance
@@ -912,6 +914,7 @@ async def update_user_balance(
amount_kopeks=amount_to_subtract,
description=request.description,
create_transaction=request.create_transaction,
payment_method=PaymentMethod.MANUAL,
)
if not success:
+1 -1
View File
@@ -344,7 +344,7 @@ async def get_transactions_statistics(
select(
Transaction.payment_method,
func.count(Transaction.id).label('count'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount'),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('total_amount'),
)
.where(
and_(
+2
View File
@@ -3288,6 +3288,8 @@ class GuestPurchase(Base):
auto_login_token = Column(Text, nullable=True)
recipient_warning = Column(String(50), nullable=True)
retry_count = Column(Integer, nullable=False, default=0, server_default='0')
receipt_uuid = Column(String(255), nullable=True, index=True)
receipt_created_at = Column(AwareDateTime(), nullable=True)
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
tariff = relationship('Tariff', lazy='selectin')
+105 -1
View File
@@ -25,6 +25,7 @@ from app.database.models import (
LandingPage,
PaymentMethod,
Tariff,
Transaction,
TransactionType,
User,
)
@@ -176,6 +177,97 @@ async def create_purchase(
return purchase
async def _create_nalogo_receipt_for_purchase(
db: AsyncSession,
purchase: GuestPurchase,
user: User,
transaction: Transaction | None = None,
) -> None:
"""Create NaloGO fiscal receipt for a guest purchase (best-effort)."""
if not settings.is_nalogo_enabled():
return
# Без payment_id нет dedup-ключа в Redis — нельзя гарантировать идемпотентность
if not purchase.payment_id:
logger.warning(
'Cannot create NaloGO receipt: purchase has no payment_id',
purchase_id=purchase.id,
)
return
# Нулевые/отрицательные суммы не фискализируем
if purchase.amount_kopeks <= 0:
return
# Защита от дублей: если у транзакции или покупки уже есть чек — не создаём новый
if transaction and transaction.receipt_uuid:
logger.info(
'NaloGO receipt already exists for guest purchase (transaction)',
purchase_id=purchase.id,
receipt_uuid=transaction.receipt_uuid,
)
return
if purchase.receipt_uuid:
logger.info(
'NaloGO receipt already exists for guest purchase (purchase)',
purchase_id=purchase.id,
receipt_uuid=purchase.receipt_uuid,
)
return
try:
from app.services.nalogo_service import NaloGoService
nalogo_service = NaloGoService()
if not nalogo_service.configured:
return
amount_rubles = purchase.amount_kopeks / 100
# Не передаём telegram_user_id в описание чека — privacy (VPN-сервис)
receipt_name = settings.get_balance_payment_description(purchase.amount_kopeks)
receipt_uuid = await nalogo_service.create_receipt(
name=receipt_name,
amount=amount_rubles,
quantity=1,
payment_id=purchase.payment_id,
telegram_user_id=user.telegram_id,
amount_kopeks=purchase.amount_kopeks,
)
if receipt_uuid:
logger.info(
'NaloGO receipt created for guest purchase',
purchase_id=purchase.id,
receipt_uuid=receipt_uuid,
saved_to_transaction=transaction is not None,
)
# Всегда сохраняем receipt_uuid на purchase (persistent dedup)
try:
purchase.receipt_uuid = receipt_uuid
purchase.receipt_created_at = datetime.now(UTC)
if transaction:
transaction.receipt_uuid = receipt_uuid
transaction.receipt_created_at = datetime.now(UTC)
await db.commit()
except Exception:
await db.rollback()
logger.warning(
'Failed to save receipt_uuid to purchase/transaction',
purchase_id=purchase.id,
receipt_uuid=receipt_uuid,
)
except Exception as exc:
from app.utils.proxy import sanitize_proxy_error
logger.error(
'Failed to create nalogo receipt for guest purchase',
purchase_id=purchase.id,
error=sanitize_proxy_error(exc),
)
async def fulfill_purchase(
db: AsyncSession,
purchase_token: str,
@@ -271,6 +363,10 @@ async def fulfill_purchase(
await _send_admin_notification(purchase, notification_tariff_name, is_pending_activation=True)
# Создаем чек через NaloGO (деньги получены, чек нужен)
await _create_nalogo_receipt_for_purchase(db, purchase, user)
await db.refresh(purchase) # guard: inner rollback may expire the object
# Clear plaintext password after email delivery
if purchase.cabinet_password:
purchase.cabinet_password = None
@@ -335,9 +431,10 @@ async def fulfill_purchase(
await db.refresh(purchase, attribute_names=['landing', 'user'])
# Create transaction so promo group auto-assignment and contest tracking work
transaction = None
try:
payment_method_enum = _resolve_payment_method(purchase.payment_method)
await create_transaction(
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
@@ -363,6 +460,12 @@ async def fulfill_purchase(
await _send_admin_notification(purchase, notification_tariff_name, is_pending_activation=False)
# Создаем чек через NaloGO
await _create_nalogo_receipt_for_purchase(db, purchase, user, transaction)
# Refresh purchase: если внутри nalogo helper был rollback, объект expired
await db.refresh(purchase)
# Clear plaintext password after email delivery — no longer needed in DB
if purchase.cabinet_password:
purchase.cabinet_password = None
@@ -1344,6 +1447,7 @@ async def _find_succeeded_provider_payment(
result = await db.execute(
select(CryptoBotPayment).where(
CryptoBotPayment.status == 'paid',
CryptoBotPayment.payload.like('{%'),
cast(CryptoBotPayment.payload, SA_JSON)['purchase_token'].as_string() == purchase_token,
)
)
+36 -7
View File
@@ -483,12 +483,14 @@ async def try_fulfill_guest_purchase(
if purchase_token is None:
return None
from app.database.crud.landing import get_purchase_by_token, update_purchase_status
from app.database.crud.landing import update_purchase_status
from app.database.models import GuestPurchase, GuestPurchaseStatus
from app.services.guest_purchase_service import fulfill_purchase
try:
existing = await get_purchase_by_token(db, purchase_token)
# FOR UPDATE prevents concurrent webhooks from double-processing the same purchase
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == purchase_token).with_for_update())
existing = result.scalars().first()
# Verify amount (skip for providers with currency conversion imprecision)
if existing and not skip_amount_check and payment_amount_kopeks != existing.amount_kopeks:
@@ -502,11 +504,20 @@ async def try_fulfill_guest_purchase(
await update_purchase_status(db, purchase_token, GuestPurchaseStatus.FAILED)
return True # consumed, even though failed
# Idempotency: skip terminal states
if existing and existing.status in (
GuestPurchaseStatus.DELIVERED.value,
GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchaseStatus.FAILED.value,
# Idempotency: skip terminal states (and code-only gifts already in PAID)
if (
existing
and existing.status
in (
GuestPurchaseStatus.DELIVERED.value,
GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchaseStatus.FAILED.value,
)
) or (
existing
and existing.status == GuestPurchaseStatus.PAID.value
and existing.is_gift
and not existing.gift_recipient_type
):
logger.info(
'Guest purchase already in terminal state, skipping',
@@ -536,6 +547,24 @@ async def try_fulfill_guest_purchase(
purchase_token_prefix=purchase_token[:5],
provider=provider_name,
)
# NaloGO receipt: payment received, fulfillment deferred until code activation
try:
await db.refresh(existing)
if existing.buyer:
from app.services.guest_purchase_service import _create_nalogo_receipt_for_purchase
await _create_nalogo_receipt_for_purchase(db, existing, existing.buyer)
else:
logger.warning(
'Code-only gift has no buyer, skipping NaloGO receipt',
purchase_token_prefix=purchase_token[:5],
buyer_user_id=existing.buyer_user_id,
)
except Exception:
logger.exception(
'Failed to create NaloGO receipt for code-only gift',
purchase_token_prefix=purchase_token[:5],
)
return True
# Fulfill: create user, subscription, deliver (commits on success)
+1 -1
View File
@@ -476,7 +476,7 @@ class ReportingService:
"""
return select(
func.count(Transaction.id),
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0),
).where(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == true(),
+1 -1
View File
@@ -76,7 +76,7 @@ async def _get_overview(db: AsyncSession) -> dict[str, object]:
today = datetime.now(UTC).date()
today_transactions = (
await db.scalar(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
func.date(Transaction.created_at) == today,
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
+2 -1
View File
@@ -24,7 +24,7 @@ from app.database.crud.user import (
get_user_by_telegram_id,
update_user,
)
from app.database.models import PromoGroup, Subscription, User, UserStatus
from app.database.models import PaymentMethod, PromoGroup, Subscription, User, UserStatus
from app.services.subscription_service import SubscriptionService
from ..dependencies import get_db_session, require_api_token
@@ -317,6 +317,7 @@ async def update_balance(
amount_kopeks=payload.amount_kopeks,
description=payload.description or 'Корректировка через веб-API',
create_transaction=payload.create_transaction,
payment_method=PaymentMethod.MANUAL,
)
if not success:
@@ -73,12 +73,13 @@ def upgrade() -> None:
)
)
# CryptoBot: payload (text) column with JSON inside, no metadata_json
# CryptoBot: payload (text) column with JSON inside, no metadata_json.
# Filter payload LIKE '{%' to skip non-JSON values (e.g. "balance_2_10000").
op.execute(
sa.text(
'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_cryptobot_payments_payload_purchase_token '
"ON cryptobot_payments ((CAST(payload AS json) ->> 'purchase_token')) "
"WHERE status = 'paid'"
"WHERE status = 'paid' AND payload LIKE '{%'"
)
)
@@ -0,0 +1,66 @@
"""fix payment_method=NULL for admin manual top-ups
Revision ID: 0044
Revises: 0043
Create Date: 2026-03-21
Data-only migration: sets payment_method='manual' on deposit transactions
that were created by admin top-ups (Cabinet API, WebAPI, Telegram bot)
but stored with payment_method=NULL due to a bug.
Strategy: exclude all known non-admin deposit patterns that legitimately
have payment_method=NULL (wheel prizes, campaigns, promo codes, referral
purchase commissions, legacy webhook duplicates). Everything remaining
with type='deposit' AND payment_method IS NULL is an admin manual top-up.
"""
from typing import Sequence, Union
from alembic import op
revision: str = '0044'
down_revision: Union[str, None] = '0043'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("""
UPDATE transactions
SET payment_method = 'manual'
WHERE type = 'deposit'
AND payment_method IS NULL
AND is_completed = TRUE
AND (description IS NULL OR (
description NOT LIKE 'Выигрыш в колесе удачи:%'
AND description NOT LIKE 'Бонус за регистрацию по кампании%'
AND description NOT LIKE 'Бонус по промокоду%'
AND description NOT LIKE 'Комиссия %'
AND description NOT LIKE 'Бонус за первое пополнение%'
AND description NOT LIKE 'Бонус за реферала%'
AND description NOT LIKE 'Восстановленный бонус%'
AND description NOT LIKE 'Пополнение через Tribute%'
AND description NOT LIKE 'Пополнение через Telegram Stars%'
))
""")
def downgrade() -> None:
op.execute("""
UPDATE transactions
SET payment_method = NULL
WHERE type = 'deposit'
AND payment_method = 'manual'
AND is_completed = TRUE
AND (description IS NULL OR (
description NOT LIKE 'Выигрыш в колесе удачи:%'
AND description NOT LIKE 'Бонус за регистрацию по кампании%'
AND description NOT LIKE 'Бонус по промокоду%'
AND description NOT LIKE 'Комиссия %'
AND description NOT LIKE 'Бонус за первое пополнение%'
AND description NOT LIKE 'Бонус за реферала%'
AND description NOT LIKE 'Восстановленный бонус%'
AND description NOT LIKE 'Пополнение через Tribute%'
AND description NOT LIKE 'Пополнение через Telegram Stars%'
))
""")
@@ -0,0 +1,35 @@
"""add receipt_uuid and receipt_created_at to guest_purchases
Revision ID: 0045
Revises: 0044
Create Date: 2026-03-21
Adds receipt_uuid and receipt_created_at columns to guest_purchases table
so that NaloGO fiscal receipt UUIDs are persisted on the purchase record
itself (not only on transaction or in Redis). This provides a persistent
DB-level dedup guard and audit trail for receipts created in the
PENDING_ACTIVATION path and code-only gift path where no Transaction
exists at receipt creation time.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0045'
down_revision: str | None = '0044'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('guest_purchases', sa.Column('receipt_uuid', sa.String(255), nullable=True))
op.add_column('guest_purchases', sa.Column('receipt_created_at', sa.DateTime(timezone=True), nullable=True))
op.create_index('ix_guest_purchases_receipt_uuid', 'guest_purchases', ['receipt_uuid'])
def downgrade() -> None:
op.drop_index('ix_guest_purchases_receipt_uuid', table_name='guest_purchases')
op.drop_column('guest_purchases', 'receipt_created_at')
op.drop_column('guest_purchases', 'receipt_uuid')