From ba79d03e389afed972296fe2bc05104aa6b883f3 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 05:43:22 +0300 Subject: [PATCH 1/4] fix: skip non-JSON payload rows in cryptobot payment index and query payload column in cryptobot_payments contains plain strings like "balance_2_10000" alongside JSON objects. CAST(payload AS json) fails on these rows during CREATE INDEX CONCURRENTLY. - Add AND payload LIKE '{%' to partial index WHERE clause in migration 0042 - Add .payload.like('{%') filter to guest_purchase_service query --- app/services/guest_purchase_service.py | 1 + .../0042_add_retry_count_and_payment_recovery_indexes.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py index 3bb36cbc..be76fd89 100644 --- a/app/services/guest_purchase_service.py +++ b/app/services/guest_purchase_service.py @@ -1344,6 +1344,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/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 '{%'" ) ) From 424496233773b4cee4e389a1172e95208b3afeaf Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 06:36:21 +0300 Subject: [PATCH 2/4] fix: add NaloGO fiscal receipt creation for landing page purchases Landing page (guest) payments were completely skipping nalogo receipt generation because the guest purchase flow returned early in payment webhook handlers before reaching the nalogo code. Added _create_nalogo_receipt_for_purchase() helper with: - payment_id null-check (Redis dedup requires it) - amount validation (skip zero/negative) - transaction.receipt_uuid duplicate guard - inner try/except with db.rollback() for receipt_uuid persistence - sanitize_proxy_error for credential-safe error logging - privacy: no telegram_user_id in receipt description sent to tax authority Called in both DELIVERED and PENDING_ACTIVATION paths. Added db.refresh(purchase) after nalogo call to handle potential session expiry from rollback inside the helper. --- app/services/guest_purchase_service.py | 93 +++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py index be76fd89..1b50b469 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,86 @@ 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', + purchase_id=purchase.id, + receipt_uuid=transaction.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, + ) + if transaction: + try: + 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 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 +352,9 @@ 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) + # Clear plaintext password after email delivery if purchase.cabinet_password: purchase.cabinet_password = None @@ -335,9 +419,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 +448,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 From ab43e74ab7484f8d3517f91e366ea395e1944b99 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 07:01:22 +0300 Subject: [PATCH 3/4] fix: manual admin top-ups missing from sales statistics Cabinet API and WebAPI created admin balance transactions with payment_method=NULL instead of 'manual', making them invisible to sales statistics filters. Changes: - Add payment_method=PaymentMethod.MANUAL to Cabinet and WebAPI balance update endpoints - Add func.abs() to all transaction amount aggregations missing it across sales stats, dashboard stats, and reporting queries - Remove redundant Python abs() on addon_revenue (SQL func.abs already applied) - Add data migration 0044 to fix historical NULL payment_method records for admin top-ups --- app/cabinet/routes/admin_sales_stats.py | 14 ++-- app/cabinet/routes/admin_users.py | 3 + app/database/crud/transaction.py | 2 +- app/services/reporting_service.py | 2 +- app/webapi/routes/stats.py | 2 +- app/webapi/routes/users.py | 3 +- ...4_fix_null_payment_method_manual_topups.py | 66 +++++++++++++++++++ 7 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 migrations/alembic/versions/0044_fix_null_payment_method_manual_topups.py 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/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/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%' + )) + """) From 90209ebef1a872665e622124a1898d52eff398e7 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 07:37:03 +0300 Subject: [PATCH 4/4] feat: add NaloGO fiscal receipts for code-only gift purchases - Create NaloGO receipt when code-only gifts (no recipient) are paid via any gateway provider, not just directed gifts - Add receipt_uuid and receipt_created_at columns to guest_purchases for persistent DB-level dedup (covers PENDING_ACTIVATION and code-only paths where no Transaction exists at receipt time) - Use SELECT ... FOR UPDATE in try_fulfill_guest_purchase to prevent concurrent webhook double-processing race condition - Expand idempotency guard to include code-only gifts already in PAID status - Add db.refresh after PENDING_ACTIVATION nalogo call to guard against inner rollback expiring the ORM object --- app/database/models.py | 2 + app/services/guest_purchase_service.py | 36 ++++++++++------ app/services/payment/common.py | 43 ++++++++++++++++--- ...045_add_receipt_uuid_to_guest_purchases.py | 35 +++++++++++++++ 4 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 migrations/alembic/versions/0045_add_receipt_uuid_to_guest_purchases.py 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 1b50b469..4185778c 100644 --- a/app/services/guest_purchase_service.py +++ b/app/services/guest_purchase_service.py @@ -199,15 +199,23 @@ async def _create_nalogo_receipt_for_purchase( if purchase.amount_kopeks <= 0: return - # Защита от дублей: если у транзакции уже есть чек — не создаём новый + # Защита от дублей: если у транзакции или покупки уже есть чек — не создаём новый if transaction and transaction.receipt_uuid: logger.info( - 'NaloGO receipt already exists for guest purchase', + '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 @@ -235,18 +243,21 @@ async def _create_nalogo_receipt_for_purchase( receipt_uuid=receipt_uuid, saved_to_transaction=transaction is not None, ) - if transaction: - try: + # Всегда сохраняем 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 transaction', - purchase_id=purchase.id, - receipt_uuid=receipt_uuid, - ) + 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 @@ -354,6 +365,7 @@ async def fulfill_purchase( # Создаем чек через 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: 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/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')