@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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_(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user