$ sudo teach IT
Модуль 3 · Invoices — управление счетами

Урок 3.3 — Удаление и срок действия инвойсов

Научимся удалять инвойсы, управлять их сроком действия, обрабатывать просроченные счета и строить систему автоматической очистки. Разберём delete_invoice(), delete_all_invoices(), expires_in и сценарии с EXPIRED статусом.

Инвойсы не живут вечно. У каждого счёта есть срок действия, после которого он переходит в статус EXPIRED. Кроме того, иногда нужно удалить счёт до его оплаты — например, если买家 передумал или товар закончился. В этом уроке мы разберём всё, что связано с жизненным циклом инвойса: от установки времени жизни до полного удаления и массовой очистки.

🔄

Жизненный цикл инвойса

Каждый инвойс в Crypto Pay проходит через несколько состояний. Понимание жизненного цикла критически важно для правильного управления счетами.

🟡
ACTIVE
Создан, ожидает оплаты
🟢
PAID
Оплачен — удалить нельзя
🔴
EXPIRED
Истёк срок — можно удалить
🗑️
DELETED
Удалён из системы

Важное правило: оплаченный инвойс нельзя удалить. API Crypto Pay возвращает ошибку при попытке удалить PAID инвойс. Это защита от случайного удаления финансовых записей. Удалять можно только счета со статусом ACTIVE или EXPIRED.

Цепочка состояний:

Создание → ACTIVE → Оплата → PAID (навсегда)

Создание → ACTIVE → Истечение срока → EXPIRED → Удаление → DELETED

🗑️

Метод delete_invoice() — удаление счёта

Метод delete_invoice() удаляет инвойс из системы Crypto Pay. После удаления счёт становится недоступен для оплаты, а его статус меняется на удалённый.

Python · Сигнатура метода
async def delete_invoice(
    self,
    invoice_id: int,
) -> bool

Метод принимает ID инвойса и возвращает True в случае успешного удаления. Если инвойс уже оплачен, выбрасывается исключение. Если инвойс с таким ID не существует — также ошибка.

Python · Базовое удаление инвойса
import asyncio
from aiosend import CryptoPay

async def main():
    cp = CryptoPay(token="YOUR_TOKEN")

    # Создаём инвойс
    invoice = await cp.create_invoice(amount=10, asset="USDT")
    print(f"Создан инвойс #{invoice.invoice_id}, статус: {invoice.status}")

    # Удаляем его
    success = await cp.delete_invoice(invoice.invoice_id)
    print(f"Удалён: {success}")

    # Проверяем — инвойс больше не существует
    try:
        deleted = await cp.get_invoice(invoice.invoice_id)
        print(f"Статус после удаления: {deleted.status}")
    except Exception as e:
        print(f"Инвойс не найден: {e}")

asyncio.run(main())

‼️ Важно: удалить можно только ACTIVE или EXPIRED

Попытка удалить PAID инвойс приведёт к ошибке API. Всегда проверяй статус перед удалением, если не уверен. Оплаченные счета хранятся в истории навсегда — это требование финансового учёта.

Shortcut: invoice.delete()

В aiosend есть удобный shortcut — метод invoice.delete() прямо на объекте Invoice. Он вызывает cp.delete_invoice(invoice_id) автоматически. Это избавляет от необходимости хранить ссылку на клиента.

Python · Shortcut invoice.delete()
import asyncio
from aiosend import CryptoPay

async def main():
    cp = CryptoPay(token="YOUR_TOKEN")
    invoice = await cp.create_invoice(amount=25, asset="USDT")

    # Shortcut — удаляем через сам объект инвойса
    success = await invoice.delete()
    # Эквивалентно: await cp.delete_invoice(invoice.invoice_id)

    print(f"Инвойс #{invoice.invoice_id} удалён: {success}")

asyncio.run(main())

Shortcut особенно удобен, когда у тебя нет прямого доступа к экземпляру CryptoPay — например, в обработчиках событий или колбэках, куда приходит только объект Invoice.

⚠️

Ошибки при удалении и их обработка

При удалении инвойсов могут возникать различные ошибки. Важно правильно их обрабатывать, чтобы избежать падения приложения.

Ситуация Исключение Решение
Инвойс уже оплачен APIError Пропустить — оплаченные счета не удаляются
Инвойс не существует APIError Проверить ID или игнорировать
Инвойс уже удалён APIError Игнорировать — счёт уже не существует
Таймаут соединения APITimeoutError Повторить попытку через некоторое время
Python · Безопасное удаление с обработкой ошибок
from aiosend import CryptoPay
from aiosend.exceptions import APIError, APITimeoutError

async def safe_delete_invoice(cp: CryptoPay, invoice_id: int) -> bool:
    """Безопасное удаление инвойса с обработкой ошибок."""
    try:
        # Проверяем статус перед удалением
        invoice = await cp.get_invoice(invoice_id)

        if invoice.status == "paid":
            print(f"Инвойс #{invoice_id} уже оплачен — удаление невозможно")
            return False

        if invoice.status in ("expired", "active"):
            success = await cp.delete_invoice(invoice_id)
            print(f"Инвойс #{invoice_id} удалён: {success}")
            return success

    except APIError as e:
        if "not found" in str(e).lower():
            print(f"Инвойс #{invoice_id} не существует")
        elif "paid" in str(e).lower():
            print(f"Инвойс #{invoice_id} уже оплачен")
        else:
            print(f"Ошибка API при удалении #{invoice_id}: {e}")
        return False
    except APITimeoutError:
        print(f"Таймаут при удалении #{invoice_id}, повторите позже")
        return False

    return False

💡 Практический совет

При массовом удалении инвойсов (например, в scheduler) не прерывай процесс из-за одной ошибки. Лови исключения для каждого инвойса отдельно и продолжай обработку остальных. Так один «проблемный» счёт не заблокирует очистку всей очереди.

🧹

delete_all_invoices() — массовое удаление

Метод delete_all_invoices() позволяет удалить несколько инвойсов по их ID за один запрос. Это эффективнее, чем вызывать delete_invoice() в цикле.

Python · Сигнатура метода
async def delete_all_invoices(
    self,
    *invoice_ids: int,
) -> bool

Принимает любое количество ID через *args (вариадический параметр). Возвращает True, если все инвойсы успешно удалены. Если хотя бы один не удался (например, оплачен), метод выбрасывает исключение.

Python · Массовое удаление инвойсов
import asyncio
from aiosend import CryptoPay

async def main():
    cp = CryptoPay(token="YOUR_TOKEN")

    # Создаём несколько инвойсов
    invoices = []
    for i in range(5):
        inv = await cp.create_invoice(
            amount=10 + i,
            asset="USDT",
            description=f"Тестовый счёт #{i+1}",
        )
        invoices.append(inv)
        print(f"Создан инвойс #{inv.invoice_id}")

    # Удаляем все разом
    ids = [inv.invoice_id for inv in invoices]
    success = await cp.delete_all_invoices(*ids)
    print(f"Все {len(ids)} инвойсов удалены: {success}")

asyncio.run(main())

Обрати внимание на звёздочку *ids — метод ожидает отдельные аргументы, а не список. Если у тебя список ID, используй распаковку (*ids).

⚠️ Ограничение: максимум 1000 ID за раз

Хотя Python позволяет передать сколько угодно аргументов, API Crypto Pay принимает не более 1000 ID за один запрос. Если нужно удалить больше — разбей на несколько вызовов по 1000 ID.

Оптимизация: удаление с предварительной фильтрацией

На практике удобно комбинировать get_invoices() с delete_all_invoices() — сначала получить список просроченных счетов, а затем массово их удалить.

Python · Массовое удаление с фильтрацией
import asyncio
from aiosend import CryptoPay, InvoiceStatus

async def clean_expired_invoices(cp: CryptoPay) -> int:
    """Удаляет все просроченные инвойсы. Возвращает количество удалённых."""
    expired = await cp.get_invoices(status=InvoiceStatus.EXPIRED)
    active = await cp.get_invoices(status=InvoiceStatus.ACTIVE)

    all_ids = [inv.invoice_id for inv in expired + active]
    if not all_ids:
        print("Нет счетов для удаления")
        return 0

    # Удаляем пачками по 1000
    deleted_count = 0
    for i in range(0, len(all_ids), 1000):
        batch = all_ids[i:i + 1000]
        try:
            await cp.delete_all_invoices(*batch)
            deleted_count += len(batch)
            print(f"Удалена пачка {i//1000 + 1}: {len(batch)} инвойсов")
        except Exception as e:
            print(f"Ошибка при удалении пачки: {e}")

    print(f"Всего удалено: {deleted_count}")
    return deleted_count

async def main():
    cp = CryptoPay(token="YOUR_TOKEN")
    await clean_expired_invoices(cp)

asyncio.run(main())

💡 Зачем удалять просроченные инвойсы?

Crypto Pay не имеет документированного лимита на количество активных инвойсов, но на практике большое количество неоплаченных счетов может замедлять работу. Регулярная очистка — хорошая практика для поддержания производительности и порядка.

⏱️

Параметр expires_in — управление сроком жизни

expires_in — это параметр метода create_invoice(), который задаёт время жизни счёта в секундах. По истечении этого времени статус инвойса автоматически меняется на EXPIRED.

Значение Человеческий формат Сценарий
1 1 секунда Минимальное значение. Для тестов.
60 1 минута Оплата «здесь и сейчас» в очереди
300 5 минут Стандартная оплата товара в боте
1800 30 минут Долгая сессия с выбором товаров
3600 1 час Корзина интернет-магазина
14400 4 часа Дневная сессия
86400 24 часа (1 день) Счета-фактуры на email
604800 7 дней Недельные предложения
2592000 30 дней Долгосрочные счета
2678400 31 день Максимальное значение (лимит API)
Python · Примеры с expires_in
import asyncio
from aiosend import CryptoPay

async def main():
    cp = CryptoPay(token="YOUR_TOKEN")

    # Быстрый счёт на 5 минут (для очереди оплаты)
    fast = await cp.create_invoice(
        amount=5,
        asset="USDT",
        expires_in=300,
        description="Быстрая оплата",
    )
    print(f"Быстрый счёт #{fast.invoice_id}, истекает: {fast.expires_at}")

    # Стандартный счёт на 1 час
    standard = await cp.create_invoice(
        amount=100,
        fiat="USD",
        expires_in=3600,
        description="Товар из корзины",
    )
    print(f"Стандартный счёт #{standard.invoice_id}")

    # Долгосрочный счёт на 7 дней
    weekly = await cp.create_invoice(
        amount=500,
        asset="USDT",
        expires_in=604800,
        description="Недельный абонемент",
    )
    print(f"Долгий счёт #{weekly.invoice_id}")

    # Без expires_in — системный срок по умолчанию
    default = await cp.create_invoice(
        amount=10,
        asset="TON",
    )
    print(f"Обычный счёт #{default.invoice_id} (срок по умолчанию)")

asyncio.run(main())

Граничные значения expires_in

Минимум: 1 секунда. Максимум: 2 678 400 секунд (31 день). Если передать 0, отрицательное число или больше максимума — API вернёт ошибку MethodValuesError. Всегда проверяй входные значения перед передачей в API.

🔴

Статус EXPIRED — просроченные счета

Когда срок жизни инвойса истекает, он автоматически переходит в статус EXPIRED. Это необратимый процесс — просроченный счёт нельзя «реанимировать» или продлить. Единственная операция, доступная для EXPIRED инвойса — удаление.

Как работает механизм истечения:

  • При создании инвойса с expires_in сервер устанавливает expires_at — точную дату истечения
  • Когда текущее время превышает expires_at, статус автоматически меняется на EXPIRED
  • Попытка оплатить просроченный счёт приводит к ошибке — Crypto Pay отклоняет платёж
  • EXPIRED инвойсы участвуют в лимитах активных счетов, поэтому их рекомендуется удалять
Python · Проверка и обработка просроченных счетов
import asyncio
from datetime import datetime
from aiosend import CryptoPay, InvoiceStatus

async def check_expired_invoices(cp: CryptoPay):
    """Проверяет просроченные инвойсы и выводит информацию."""
    # Получаем все просроченные счета
    expired = await cp.get_invoices(status=InvoiceStatus.EXPIRED)
    print(f"Найдено просроченных счетов: {len(expired)}")

    for inv in expired:
        print(f"  #{inv.invoice_id}: {inv.amount} {inv.asset}")
        print(f"    Создан: {inv.created_at}")
        print(f"    Истёк: {inv.expires_at}")

        # Рассчитываем, как давно истёк
        if inv.expires_at:
            expires_dt = datetime.fromisoformat(inv.expires_at)
            now = datetime.now()
            diff = now - expires_dt
            hours = diff.total_seconds() / 3600
            print(f"    Просрочен на {hours:.1f} часов")

    return expired

async def main():
    cp = CryptoPay(token="YOUR_TOKEN")
    await check_expired_invoices(cp)

asyncio.run(main())

⚠️ EXPIRED ≠ DELETED

Просроченный инвойс (EXPIRED) — это всё ещё существующий объект в системе. Его можно получить через get_invoices(status="expired"). Он занимает место в истории. Чтобы полностью избавиться от него, нужно явно вызвать delete_invoice().

Проверка даты истечения

Дата истечения хранится в поле expires_at объекта Invoice. Это строка в формате ISO 8601. Ты можешь анализировать эту дату для превентивных действий — например, уведомлять пользователя, что счёт скоро истечёт.

Python · Превентивная проверка expire
from datetime import datetime, timedelta
from aiosend import CryptoPay, InvoiceStatus

async def find_almost_expired(cp: CryptoPay, threshold_minutes: int = 10):
    """
    Находит активные инвойсы, которые скоро истекают.
    threshold_minutes — за сколько минут до истечения считать «скоро».
    """
    active = await cp.get_invoices(status=InvoiceStatus.ACTIVE)
    now = datetime.now()
    threshold = timedelta(minutes=threshold_minutes)
    almost_expired = []

    for inv in active:
        if inv.expires_at:
            expires_dt = datetime.fromisoformat(inv.expires_at)
            time_left = expires_dt - now
            if timedelta(0) < time_left <= threshold:
                almost_expired.append(inv)
                print(
                    f"Счёт #{inv.invoice_id} истекает через "
                    f"{time_left.seconds // 60} минут"
                )

    return almost_expired
⏰

Scheduler для автоматической очистки

В реальном приложении удобно запускать фоновую задачу, которая регулярно чистит просроченные и старые активные инвойсы. Рассмотрим два подхода: простой asyncio-цикл и интеграцию с APScheduler.

Вариант 1: Asyncio-цикл

Самый простой способ — запустить фоновую корутину с бесконечным циклом.

Python · Фоновый cleanup через asyncio
import asyncio
from aiosend import CryptoPay, InvoiceStatus

class InvoiceCleaner:
    """Автоматическая очистка просроченных инвойсов."""

    def __init__(self, cp: CryptoPay, interval: int = 3600):
        self.cp = cp
        self.interval = interval  # секунд между очистками
        self._task: asyncio.Task | None = None

    async def clean_once(self) -> int:
        """Один цикл очистки. Возвращает количество удалённых."""
        expired = await self.cp.get_invoices(status=InvoiceStatus.EXPIRED)
        active = await self.cp.get_invoices(status=InvoiceStatus.ACTIVE)

        # Удаляем только просроченные (active оставляем)
        ids = [inv.invoice_id for inv in expired]
        if not ids:
            return 0

        deleted = 0
        for i in range(0, len(ids), 1000):
            batch = ids[i:i + 1000]
            try:
                await self.cp.delete_all_invoices(*batch)
                deleted += len(batch)
            except Exception as e:
                print(f"Ошибка при очистке пачки: {e}")

        return deleted

    async def run_forever(self):
        """Запускает бесконечный цикл очистки."""
        while True:
            try:
                count = await self.clean_once()
                if count:
                    print(f"Очистка: удалено {count} просроченных инвойсов")
            except Exception as e:
                print(f"Ошибка в цикле очистки: {e}")
            await asyncio.sleep(self.interval)

    def start(self):
        """Запускает фоновую задачу."""
        self._task = asyncio.create_task(self.run_forever())

    async def stop(self):
        """Останавливает фоновую задачу."""
        if self._task:
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                pass

# Использование:
async def main():
    cp = CryptoPay(token="YOUR_TOKEN")
    cleaner = InvoiceCleaner(cp, interval=1800)  # каждые 30 минут
    cleaner.start()

    # Основная логика приложения...
    await asyncio.sleep(7200)  # работаем 2 часа

    await cleaner.stop()

asyncio.run(main())

Вариант 2: APScheduler (для больших проектов)

Если ты используешь APScheduler, интеграция с aiosend тоже проста.

Python · Интеграция с APScheduler
# pip install apscheduler
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from aiosend import CryptoPay, InvoiceStatus

async def cleanup_job():
    """Задача для APScheduler."""
    cp = CryptoPay(token="YOUR_TOKEN")
    expired = await cp.get_invoices(status=InvoiceStatus.EXPIRED)
    ids = [inv.invoice_id for inv in expired]

    if not ids:
        return

    for i in range(0, len(ids), 1000):
        batch = ids[i:i + 1000]
        try:
            await cp.delete_all_invoices(*batch)
            print(f"APScheduler: удалено {len(batch)} просроченных инвойсов")
        except Exception as e:
            print(f"APScheduler: ошибка {e}")

# Настройка планировщика
scheduler = AsyncIOScheduler()

# Запуск каждый день в 03:00
scheduler.add_job(
    cleanup_job,
    trigger="cron",
    hour=3,
    minute=0,
)

scheduler.start()

# Основная логика приложения...

💡 Рекомендации по расписанию очистки

Для небольших проектов достаточно запускать очистку раз в 1-6 часов. Для крупных магазинов — раз в 30-60 минут. Выбирай интервал так, чтобы не превышать лимиты API (10 запросов в секунду на одно приложение).

🧩

Практический скрипт управления инвойсами

Соберём всё вместе: создание, проверка срока, удаление и очистка в одном скрипте с интерактивным меню.

Python · Полный скрипт управления
import asyncio
import sys
from aiosend import CryptoPay, InvoiceStatus
from aiosend.exceptions import APIError

class InvoiceManager:
    """Менеджер для управления жизненным циклом инвойсов."""

    def __init__(self, token: str):
        self.cp = CryptoPay(token=token)

    async def create_with_expiry(
        self,
        amount: float,
        asset: str,
        expires_in: int,
        description: str = "",
    ):
        """Создаёт инвойс с указанным сроком жизни."""
        invoice = await self.cp.create_invoice(
            amount=amount,
            asset=asset,
            expires_in=expires_in,
            description=description,
        )
        print(f"✅ Создан инвойс #{invoice.invoice_id}")
        print(f"   Сумма: {invoice.amount} {invoice.asset}")
        print(f"   Статус: {invoice.status}")
        print(f"   Истекает: {invoice.expires_at}")
        print(f"   Ссылка: {invoice.bot_invoice_url}")
        return invoice

    async def get_status(self, invoice_id: int):
        """Получает актуальный статус инвойса."""
        try:
            invoice = await self.cp.get_invoice(invoice_id)
            print(f"Инвойс #{invoice_id}:")
            print(f"  Статус: {invoice.status}")
            print(f"  Создан: {invoice.created_at}")
            print(f"  Истекает: {invoice.expires_at or 'N/A'}")
            if invoice.status == "paid":
                print(f"  Оплачено: {invoice.paid_amount} {invoice.paid_asset}")
                print(f"  Время оплаты: {invoice.paid_at}")
            return invoice
        except APIError as e:
            print(f"❌ Инвойс #{invoice_id} не найден: {e}")
            return None

    async def delete_if_expired(self, invoice_id: int) -> bool:
        """Удаляет инвойс, только если он просрочен."""
        try:
            invoice = await self.cp.get_invoice(invoice_id)
            if invoice.status == "expired":
                await invoice.delete()
                print(f"🗑️ Просроченный инвойс #{invoice_id} удалён")
                return True
            elif invoice.status == "paid":
                print(f"❌ Инвойс #{invoice_id} оплачен — удалить нельзя")
                return False
            else:
                print(f"⏳ Инвойс #{invoice_id} ещё активен (статус: {invoice.status})")
                return False
        except APIError as e:
            print(f"❌ Ошибка: {e}")
            return False

    async def clean_all_expired(self) -> int:
        """Массово удаляет все просроченные инвойсы."""
        expired = await self.cp.get_invoices(status=InvoiceStatus.EXPIRED)
        if not expired:
            print("✅ Нет просроченных инвойсов")
            return 0

        ids = [inv.invoice_id for inv in expired]
        for i in range(0, len(ids), 1000):
            batch = ids[i:i + 1000]
            try:
                await self.cp.delete_all_invoices(*batch)
            except APIError as e:
                print(f"Ошибка при удалении пачки: {e}")

        print(f"🗑️ Удалено просроченных инвойсов: {len(ids)}")
        return len(ids)

    async def summary(self):
        """Показывает сводку по всем инвойсам."""
        active = await self.cp.get_invoices(status=InvoiceStatus.ACTIVE)
        paid = await self.cp.get_invoices(status=InvoiceStatus.PAID)
        expired = await self.cp.get_invoices(status=InvoiceStatus.EXPIRED)

        print("\n📊 Сводка по инвойсам:")
        print(f"   🟡 Активных: {len(active)}")
        print(f"   🟢 Оплаченных: {len(paid)}")
        print(f"   🔴 Просроченных: {len(expired)}")
        print(f"   📦 Всего: {len(active) + len(paid) + len(expired)}")

# Демонстрация
async def main():
    manager = InvoiceManager(token="YOUR_TOKEN")

    # 1. Создаём инвойсы с разным сроком
    await manager.create_with_expiry(10, "USDT", 60, "Тест 1 минута")
    await manager.create_with_expiry(25, "TON", 3600, "Тест 1 час")

    # 2. Показываем сводку
    await manager.summary()

    # 3. Удаляем просроченные
    await manager.clean_all_expired()

    # 4. Повторная сводка
    await manager.summary()

asyncio.run(main())
⚠️

Граничные случаи и ограничения

Попытка удалить оплаченный инвойс

Crypto Pay API блокирует удаление оплаченных счетов навсегда. Это финансовая защита: удаление оплаченного инвойса стёрло бы историю транзакций. Если тебе нужно «скрыть» оплаченный счёт из интерфейса пользователя — делай это на своей стороне, не пытаясь удалить его через API.

Удаление несуществующего инвойса

При попытке удалить инвойс с ID, которого не существует, API возвращает ошибку. Нет способа «проверить существование» без попытки получить или удалить. Рекомендуется оборачивать удаление в try-except.

Гонка при удалении (race condition)

Если инвойс оплачивается в тот самый момент, когда ты его удаляешь, возможна гонка. Что произойдёт? Если платёж прошёл раньше — delete_invoice вернёт ошибку (оплачен нельзя удалить). Если удаление прошло раньше — платёж будет отклонён. В любом случае, деньги не пропадут: либо платёж завершится, либо отклонится.

Статус по умолчанию (без expires_in)

Если не указывать expires_in, Crypto Pay использует срок действия по умолчанию. Обычно это 2 часа, но администрация может изменить это значение для конкретного приложения. Не полагайся на точное значение по умолчанию — всегда указывай expires_in явно, если время жизни критично.

delete_invoice vs invoice.delete()

Оба метода функционально идентичны. invoice.delete() — это shortcut, который внутри вызывает cp.delete_invoice(), используя сохранённую в Invoice ссылку на клиента. Разницы в поведении или производительности нет. Выбирай тот, который удобнее в конкретном контексте.

📌

Что важно запомнить

1️⃣
Удалить можно только ACTIVE или EXPIRED. Оплаченные инвойсы (PAID) удалить нельзя — это защита от потери финансовых данных.
2️⃣
delete_invoice() и invoice.delete() — два способа удалить один счёт. delete_all_invoices() — массовое удаление до 1000 ID за раз.
3️⃣
expires_in — от 1 до 2 678 400 секунд (31 день). Меньше 1 или больше 31 дня — ошибка. Без указания — срок по умолчанию (~2 часа).
4️⃣
EXPIRED ≠ DELETED. Просроченный счёт всё ещё существует и занимает место. Его нужно явно удалять.
5️⃣
Автоматическая очистка — хорошая практика. Используй asyncio-цикл или APScheduler для регулярного удаления просроченных счетов.
6️⃣
Всегда обрабатывай ошибки. Удаление может упасть из-за таймаута, неверного ID или оплаченного статуса. Оборачивай в try-except.
7️⃣
expires_at — поле инвойса с датой истечения. Используй его для превентивных уведомлений пользователя о скором истечении счёта.
🎯

Практическое задание

Задание: Напиши систему автоочистки с уведомлениями

Создай класс SmartInvoiceCleaner, который не просто удаляет просроченные инвойсы, но и:

  • Проверяет активные инвойсы и предупреждает о тех, что истекают в ближайшие 15 минут
  • Удаляет только те просроченные инвойсы, которые «висят» больше 1 часа (не трогает «свежие» EXPIRED)
  • Ведёт лог: сколько удалено, сколько предупреждений отправлено
  • Работает в фоне с настраиваемым интервалом
  • Безопасно обрабатывает ошибки для каждого инвойса отдельно

Подсказка:

Python · Шаблон решения
import asyncio
from datetime import datetime, timedelta
from aiosend import CryptoPay, InvoiceStatus
from aiosend.exceptions import APIError

class SmartInvoiceCleaner:
    """Умная очистка с предупреждениями и фильтрацией."""

    def __init__(
        self,
        cp: CryptoPay,
        interval: int = 1800,
        warn_minutes: int = 15,
        expired_threshold_hours: int = 1,
    ):
        self.cp = cp
        self.interval = interval
        self.warn_minutes = warn_minutes
        self.expired_threshold = timedelta(hours=expired_threshold_hours)
        self._task: asyncio.Task | None = None
        self.stats = {"deleted": 0, "warnings": 0, "errors": 0}

    async def check_almost_expired(self) -> list:
        """Находит активные инвойсы, истекающие скоро."""
        active = await self.cp.get_invoices(status=InvoiceStatus.ACTIVE)
        now = datetime.now()
        warning_delta = timedelta(minutes=self.warn_minutes)
        warnings = []

        for inv in active:
            if inv.expires_at:
                expires_dt = datetime.fromisoformat(inv.expires_at)
                time_left = expires_dt - now
                if timedelta(0) < time_left <= warning_delta:
                    warnings.append(inv)
                    self.stats["warnings"] += 1

        return warnings

    async def clean_stale_expired(self) -> int:
        """Удаляет 'старые' просроченные инвойсы (висят > N часов)."""
        expired = await self.cp.get_invoices(status=InvoiceStatus.EXPIRED)
        now = datetime.now()
        deleted = 0

        for inv in expired:
            if inv.expires_at:
                expires_dt = datetime.fromisoformat(inv.expires_at)
                age = now - expires_dt
                if age >= self.expired_threshold:
                    try:
                        await inv.delete()
                        deleted += 1
                        self.stats["deleted"] += 1
                    except APIError:
                        self.stats["errors"] += 1

        return deleted

    async def run_once(self):
        """Один цикл проверки."""
        warnings = await self.check_almost_expired()
        if warnings:
            print(f"⚠️ {len(warnings)} счетов скоро истекают")
            for w in warnings:
                print(f"   #{w.invoice_id}: {w.amount} {w.asset}")

        deleted = await self.clean_stale_expired()
        if deleted:
            print(f"🗑️ Удалено старых просроченных: {deleted}")

    async def run_forever(self):
        while True:
            try:
                await self.run_once()
            except Exception as e:
                print(f"Ошибка в cleaner: {e}")
            await asyncio.sleep(self.interval)

    def start(self):
        self._task = asyncio.create_task(self.run_forever())

    async def stop(self):
        if self._task:
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                pass
                    print(f"Итоговая статистика: {self.stats}")
⚡

Синхронный вариант: удаление через SyncCryptoPay

Если ты работаешь в синхронном коде (Flask, простые скрипты), используй SyncCryptoPay. Все методы удаления доступны и в синхронной версии:

Python · Синхронное удаление
from aiosend import SyncCryptoPay

cp = SyncCryptoPay(token="YOUR_TOKEN")

# Создаём инвойс
invoice = cp.create_invoice(amount=10, asset="USDT")
print(f"Создан счёт #{invoice.invoice_id}")

# Удаляем
success = cp.delete_invoice(invoice.invoice_id)
print(f"Удалён: {success}")

# Или через shortcut
another = cp.create_invoice(amount=5, asset="TON")
another.delete()  # синхронно

# Массовое удаление
cp.delete_all_invoices(1, 2, 3)

Синхронная работа с delete

Все методы: delete_invoice(), delete_all_invoices(), invoice.delete() — доступны и в синхронной версии без await. Функционально они идентичны асинхронным аналогам.

📊

Логирование и статистика удалений

В Production-среде полезно вести статистику удалений: сколько, когда и каких инвойсов было удалено. Это помогает отслеживать состояние системы и выявлять аномалии.

Python · Система логирования
import asyncio
import logging
from datetime import datetime
from aiosend import CryptoPay, InvoiceStatus

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("invoice_cleaner")

class LoggingInvoiceCleaner:
    """Очистка инвойсов с подробным логированием."""

    def __init__(self, cp: CryptoPay):
        self.cp = cp
        self.stats = {
            "total_deleted": 0,
            "total_attempts": 0,
            "total_errors": 0,
            "last_run": None,
        }

    async def clean_and_log(self) -> dict:
        """Удаляет просроченные инвойсы и логирует результат."""
        self.stats["total_attempts"] += 1
        self.stats["last_run"] = datetime.now().isoformat()

        expired = await self.cp.get_invoices(status=InvoiceStatus.EXPIRED)
        logger.info(f"Найдено просроченных инвойсов: {len(expired)}")

        deleted_ids = []
        error_ids = []

        for inv in expired:
            try:
                await inv.delete()
                deleted_ids.append(inv.invoice_id)
                self.stats["total_deleted"] += 1
                logger.debug(f"Удалён инвойс #{inv.invoice_id}")
            except Exception as e:
                error_ids.append(inv.invoice_id)
                self.stats["total_errors"] += 1
                logger.error(f"Ошибка при удалении #{inv.invoice_id}: {e}")

        run_stats = {
            "found": len(expired),
            "deleted": len(deleted_ids),
            "errors": len(error_ids),
            "deleted_ids": deleted_ids,
            "error_ids": error_ids,
            "timestamp": self.stats["last_run"],
        }

        logger.info(
            f"Очистка завершена: удалено {len(deleted_ids)}, "
            f"ошибок {len(error_ids)}"
        )
        return run_stats

    def get_summary(self) -> dict:
        """Возвращает сводную статистику."""
        return self.stats

💡 Практический совет по логированию

В Production используй structlog или loguru вместо стандартного logging. Добавь метку приложения, версию и environment в каждую запись. Это упростит отладку и мониторинг в системах типа Grafana/Prometheus.

Урок 3.3: Удаление и срок действия инвойсов

5 вопросов