diff --git a/app/cabinet/routes/admin_sales_stats.py b/app/cabinet/routes/admin_sales_stats.py index 71595d83..5abff4c9 100644 --- a/app/cabinet/routes/admin_sales_stats.py +++ b/app/cabinet/routes/admin_sales_stats.py @@ -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) diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 3d049884..30ec3d3c 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -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: diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index 128ea3d8..227d2a0f 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -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_( diff --git a/app/database/models.py b/app/database/models.py index 0bf958eb..2d6cc9d8 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -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') diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py index 3bb36cbc..4185778c 100644 --- a/app/services/guest_purchase_service.py +++ b/app/services/guest_purchase_service.py @@ -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, ) ) diff --git a/app/services/payment/common.py b/app/services/payment/common.py index 4bfc1a0b..9e4729f0 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -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) diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py index b7995295..0101b261 100644 --- a/app/services/reporting_service.py +++ b/app/services/reporting_service.py @@ -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(), diff --git a/app/webapi/routes/stats.py b/app/webapi/routes/stats.py index e105d843..f643f7f0 100644 --- a/app/webapi/routes/stats.py +++ b/app/webapi/routes/stats.py @@ -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), diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py index e5c7c28e..14d14cc5 100644 --- a/app/webapi/routes/users.py +++ b/app/webapi/routes/users.py @@ -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: diff --git a/migrations/alembic/versions/0042_add_retry_count_and_payment_recovery_indexes.py b/migrations/alembic/versions/0042_add_retry_count_and_payment_recovery_indexes.py index 6196cb8c..7ce4057f 100644 --- a/migrations/alembic/versions/0042_add_retry_count_and_payment_recovery_indexes.py +++ b/migrations/alembic/versions/0042_add_retry_count_and_payment_recovery_indexes.py @@ -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 '{%'" ) ) diff --git a/migrations/alembic/versions/0044_fix_null_payment_method_manual_topups.py b/migrations/alembic/versions/0044_fix_null_payment_method_manual_topups.py new file mode 100644 index 00000000..60b98981 --- /dev/null +++ b/migrations/alembic/versions/0044_fix_null_payment_method_manual_topups.py @@ -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%' + )) + """) diff --git a/migrations/alembic/versions/0045_add_receipt_uuid_to_guest_purchases.py b/migrations/alembic/versions/0045_add_receipt_uuid_to_guest_purchases.py new file mode 100644 index 00000000..1ff945d8 --- /dev/null +++ b/migrations/alembic/versions/0045_add_receipt_uuid_to_guest_purchases.py @@ -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')