From 1e4cb43ac7870cd3e3e78036dd207a5542cdc4aa Mon Sep 17 00:00:00 2001 From: hexdev Date: Mon, 3 Aug 2026 00:01:00 +0700 Subject: [PATCH] feat(rw): remnawave integration --- .python-version | 1 + alembic/versions/c58e44d15cca_user_balance.py | 2 +- config.py | 9 + external/pally.py | 3 +- external/rw.py | 480 ++++++++++++++++++ requirements.txt | 3 +- routes/orders.py | 11 +- routes/payments/pally.py | 11 +- send_pally_webhook.py | 106 ++++ services/subscriptions.py | 32 +- 10 files changed, 640 insertions(+), 18 deletions(-) create mode 100644 .python-version create mode 100644 external/rw.py create mode 100644 send_pally_webhook.py diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..4eba2a6 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13.0 diff --git a/alembic/versions/c58e44d15cca_user_balance.py b/alembic/versions/c58e44d15cca_user_balance.py index 30577c8..ee822dd 100644 --- a/alembic/versions/c58e44d15cca_user_balance.py +++ b/alembic/versions/c58e44d15cca_user_balance.py @@ -22,7 +22,7 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### op.create_unique_constraint(None, 'invoices', ['id']) - op.add_column('users', sa.Column('balance', sa.FLOAT(precision=2), nullable=False)) + op.add_column('users', sa.Column('balance', sa.FLOAT(precision=2), nullable=False, default=0)) # ### end Alembic commands ### diff --git a/config.py b/config.py index fcea463..5fad9ba 100644 --- a/config.py +++ b/config.py @@ -21,8 +21,17 @@ class Settings(BaseSettings): pally_shop_id: str = Field() pally_token: str = Field() + remnawave_base_url: str = Field() + remnawave_token: str = Field() + remnawave_default_squads_raw: str = Field(alias="REMNAWAVE_DEFAULT_SQUADS_UUIDS") + minimal_deposit: int = Field() + @computed_field + @property + def remnawave_default_squads(self) -> list[str]: + return self.remnawave_default_squads_raw.split(",") + @computed_field @property def db_url(self) -> str: diff --git a/external/pally.py b/external/pally.py index 75c4080..4adef1d 100644 --- a/external/pally.py +++ b/external/pally.py @@ -224,8 +224,9 @@ class BillService(BaseService): super().__init__(session) self._payer_pays_commission = payer_pays_commission - async def create( # noqa: PLR0917 + async def create( self, + *, amount: float, shop_id: str, order_id: str | None = None, diff --git a/external/rw.py b/external/rw.py new file mode 100644 index 0000000..7d73246 --- /dev/null +++ b/external/rw.py @@ -0,0 +1,480 @@ +import logging +import re +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from uuid import UUID + +from remnawave import RemnawaveSDK +from remnawave.models import ( + CreateUserRequestDto, + DeleteUserHwidDeviceResponseDto, + HWIDDeleteRequest, + UpdateUserRequestDto, +) +from remnawave.models.hwid import HwidDeviceDto + +from config import cfg + +logger = logging.getLogger(__name__) + +RW_USERNAME_MAX_LENGTH = 36 + + +# ───────────────────────────────────────────────────────────────── +# Dataclass — унифицированное представление пользователя RW +# ───────────────────────────────────────────────────────────────── + + +@dataclass +class RWUserInfo: + uuid: str + username: str + status: str # "ACTIVE", "DISABLED", "EXPIRED", "ON_HOLD", "LIMITED" + expire_at: datetime + used_traffic_bytes: float + traffic_limit_bytes: int # 0 = unlimited + hwid_device_limit: int | None # None = unlimited + active_squads: list[dict] # [{"uuid": "...", "name": "..."}] + description: str | None + short_uuid: str + + +# ───────────────────────────────────────────────────────────────── +# Внутренние хелперы +# ───────────────────────────────────────────────────────────────── + + +def _parse_user(user_dto) -> RWUserInfo: + """Преобразовать UserResponseDto → RWUserInfo.""" + active_squads: list[dict] = [] + for squad in user_dto.active_internal_squads or []: + active_squads.append({"uuid": str(squad.uuid), "name": squad.name}) + + # UserStatus может быть enum-объектом или строкой — нормализуем + status_raw = user_dto.status + if hasattr(status_raw, "value"): + status_str = str(status_raw.value).upper() + else: + status_str = str(status_raw).upper() + + # Трафик лежит в user_traffic.used_traffic_bytes + used_traffic: float = 0.0 + if user_dto.user_traffic is not None: + used_traffic = float(user_dto.user_traffic.used_traffic_bytes or 0) + + return RWUserInfo( + uuid=str(user_dto.uuid), + username=user_dto.username, + status=status_str, + expire_at=user_dto.expire_at, + used_traffic_bytes=used_traffic, + traffic_limit_bytes=int(user_dto.traffic_limit_bytes or 0), + hwid_device_limit=user_dto.hwid_device_limit, + active_squads=active_squads, + description=user_dto.description, + short_uuid=user_dto.short_uuid, + ) + + +def _build_rw_username(*, telegram_id: int | None, username: str | None) -> str | None: + if telegram_id is not None: + return f"tg_{telegram_id}" + + if not username: + return None + + sanitized_username = re.sub(r"[^a-zA-Z0-9_-]+", "_", username).strip("_") + if not sanitized_username: + return None + + return f"web_{sanitized_username}"[:RW_USERNAME_MAX_LENGTH] + + +# ───────────────────────────────────────────────────────────────── +# Публичные функции — обёртки с перехватом ошибок +# ───────────────────────────────────────────────────────────────── + + +async def get_user_by_telegram_id( + sdk: RemnawaveSDK, + telegram_id: int, +) -> RWUserInfo | None: + """ + Получить первого пользователя Remnawave, привязанного к Telegram ID. + Возвращает None, если пользователь не найден или Remnawave недоступен. + """ + try: + users_list = await sdk.users.get_users_by_telegram_id(str(telegram_id)) + if not users_list: + return None + return _parse_user(users_list[0]) + except Exception as exc: + logger.warning( + "Failed to get RW user by telegram_id=%s: %s", + telegram_id, + exc, + ) + return None + + +async def get_user_by_username( + sdk: RemnawaveSDK, + username: str, +) -> RWUserInfo | None: + try: + user = await sdk.users.get_user_by_username(username=username) + if user is None: + return None + return _parse_user(user) + except Exception as exc: + logger.warning( + "Failed to get RW user by username=%s: %s", + username, + exc, + ) + return None + + +async def get_all_squads(sdk: RemnawaveSDK) -> list[dict]: + """ + Получить все Internal Squads из Remnawave. + Возвращает список [{"uuid": "...", "name": "..."}]. + При ошибке возвращает пустой список. + """ + try: + response = await sdk.internal_squads.get_internal_squads() + result: list[dict] = [] + # GetAllInternalSquadsResponseDto имеет поле .internal_squads + for squad in response.internal_squads: + result.append({"uuid": str(squad.uuid), "name": squad.name}) + return result + except Exception as exc: + logger.warning("Failed to get all internal squads: %s", exc) + return [] + + +async def add_days( + sdk: RemnawaveSDK, + user_uuid: str, + days: int, +) -> datetime | None: + """ + Добавить N дней к expire_at пользователя. + Возвращает новую дату окончания или None при ошибке. + """ + try: + # Получаем актуальный expire_at — нельзя слепо использовать + # закешированное значение, чтобы не потерять изменения других агентов + user_dto = await sdk.users.get_user_by_uuid(uuid=user_uuid) + if user_dto is None or user_dto.expire_at is None: + logger.warning("Cannot add days: user %s has no expire_at", user_uuid) + return None + + # Обеспечиваем timezone-aware datetime + current_expire = user_dto.expire_at + if current_expire.tzinfo is None: + current_expire = current_expire.replace(tzinfo=UTC) + + new_expire = current_expire + timedelta(days=days) + + result = await sdk.users.update_user( + UpdateUserRequestDto( + uuid=UUID(user_uuid), + expire_at=new_expire, + ) + ) + if result is None: + return None + + new_dt = result.expire_at + if new_dt is not None and new_dt.tzinfo is None: + new_dt = new_dt.replace(tzinfo=UTC) + return new_dt + except Exception as exc: + logger.warning("Failed to add %d days for user %s: %s", days, user_uuid, exc) + return None + + +async def remove_days( + sdk: RemnawaveSDK, + user_uuid: str, + days: int, +) -> datetime | None: + """ + Вычесть N дней из expire_at пользователя. + Возвращает новую дату окончания или None при ошибке. + """ + try: + user_dto = await sdk.users.get_user_by_uuid(uuid=user_uuid) + if user_dto is None or user_dto.expire_at is None: + logger.warning("Cannot remove days: user %s has no expire_at", user_uuid) + return None + + current_expire = user_dto.expire_at + if current_expire.tzinfo is None: + current_expire = current_expire.replace(tzinfo=UTC) + + new_expire = current_expire - timedelta(days=days) + + result = await sdk.users.update_user( + UpdateUserRequestDto( + uuid=UUID(user_uuid), + expire_at=new_expire, + ) + ) + if result is None: + return None + + new_dt = result.expire_at + if new_dt is not None and new_dt.tzinfo is None: + new_dt = new_dt.replace(tzinfo=UTC) + return new_dt + except Exception as exc: + logger.warning("Failed to remove %d days for user %s: %s", days, user_uuid, exc) + return None + + +async def set_hwid_limit( + sdk: RemnawaveSDK, + user_uuid: str, + limit: int, +) -> bool: + """ + Установить лимит HWID-устройств. + limit == 0 → убрать ограничение (None). + Возвращает True при успехе, False при ошибке. + """ + try: + hwid_value: int | None = None if limit == 0 else limit + await sdk.users.update_user( + UpdateUserRequestDto( + uuid=UUID(user_uuid), + hwid_device_limit=hwid_value, + ) + ) + return True + except Exception as exc: + logger.warning( + "Failed to set HWID limit=%s for user %s: %s", + limit, + user_uuid, + exc, + ) + return False + + +async def set_description( + sdk: RemnawaveSDK, + user_uuid: str, + note: str, +) -> bool: + """ + Установить заметку (description) пользователя в Remnawave. + Возвращает True при успехе, False при ошибке. + """ + try: + await sdk.users.update_user( + UpdateUserRequestDto( + uuid=UUID(user_uuid), + description=note, + ) + ) + return True + except Exception as exc: + logger.warning( + "Failed to set description for user %s: %s", + user_uuid, + exc, + ) + return False + + +async def reset_traffic( + sdk: RemnawaveSDK, + user_uuid: str, +) -> bool: + """ + Сбросить счётчик использованного трафика пользователя. + Возвращает True при успехе, False при ошибке. + """ + try: + await sdk.users.reset_user_traffic(uuid=user_uuid) + return True + except Exception as exc: + logger.warning("Failed to reset traffic for user %s: %s", user_uuid, exc) + return False + + +async def disable_user( + sdk: RemnawaveSDK, + user_uuid: str, +) -> bool: + """ + Отключить пользователя (статус → DISABLED). + Возвращает True при успехе, False при ошибке. + """ + try: + await sdk.users.disable_user(uuid=user_uuid) + return True + except Exception as exc: + logger.warning("Failed to disable user %s: %s", user_uuid, exc) + return False + + +async def enable_user( + sdk: RemnawaveSDK, + user_uuid: str, +) -> bool: + """ + Включить пользователя (статус → ACTIVE). + Возвращает True при успехе, False при ошибке. + """ + try: + await sdk.users.enable_user(uuid=user_uuid) + return True + except Exception as exc: + logger.warning("Failed to enable user %s: %s", user_uuid, exc) + return False + + +async def update_squads( + sdk: RemnawaveSDK, + user_uuid: str, + squad_uuid_list: list[str], +) -> bool: + """ + Полностью заменить список Internal Squads пользователя. + squad_uuid_list — строковые UUID всех squad'ов (полный новый список). + Возвращает True при успехе, False при ошибке. + """ + try: + await sdk.users.update_user( + UpdateUserRequestDto( + uuid=UUID(user_uuid), + active_internal_squads=[UUID(s) for s in squad_uuid_list], + ) + ) + return True + except Exception as exc: + logger.warning( + "Failed to update squads for user %s: %s", + user_uuid, + exc, + ) + return False + + +async def create_user( + *, + sdk: RemnawaveSDK, + username: str, + telegram_id: int | None, + expire_at: datetime, + hwid_device_limit: int | None, + squad_uuids: list[str], +) -> RWUserInfo | None: + """ + Создать нового пользователя в Remnawave. + hwid_device_limit=None → без ограничений. + Возвращает RWUserInfo при успехе, None при ошибке. + """ + try: + dto = CreateUserRequestDto( + username=username, + telegram_id=telegram_id, + expire_at=expire_at, + hwid_device_limit=hwid_device_limit, + active_internal_squads=[UUID(s) for s in squad_uuids] if squad_uuids else None, + ) + result = await sdk.users.create_user(dto) + if result is None: + return None + return _parse_user(result) + except Exception as exc: + logger.warning("Failed to create user username=%s: %s", username, exc) + return None + + +async def get_hwid_list(sdk: RemnawaveSDK, user_uuid: str) -> list[HwidDeviceDto] | None: + try: + resp = await sdk.hwid.get_hwid_user(user_uuid) + return resp.devices + except Exception: + logger.exception("failed to fetch hwid list for user=%s", user_uuid) + return + + +async def delete_hwid(sdk: RemnawaveSDK, user_uuid: str, hwid: str) -> bool: + body = HWIDDeleteRequest(user_uuid=user_uuid, hwid=hwid) + try: + resp = await sdk.hwid.delete_hwid_to_user(body) + return isinstance(resp, DeleteUserHwidDeviceResponseDto) + except Exception: + logger.exception("failed to delete hwid=%s for user=%s", hwid, user_uuid) + return False + + +async def update_expire_at(sdk: RemnawaveSDK, user_uuid: str, expire_at: datetime): + dto = UpdateUserRequestDto(uuid=user_uuid, expire_at=expire_at) # type: ignore + try: + await sdk.users.update_user(body=dto) + return True + except Exception: + logger.exception("failed to update expire_at=%s for user=%s", str(expire_at), user_uuid) + return False + + +def get_sdk() -> RemnawaveSDK | None: + if not cfg.remnawave_base_url or not cfg.remnawave_token: + return None + + return RemnawaveSDK(base_url=cfg.remnawave_base_url, token=cfg.remnawave_token) + + +async def sync_subscription_by_telegram_id( + *, + expires_at: datetime, + devices: int, + telegram_id: int | None = None, + username: str | None = None, +) -> bool: + sdk = get_sdk() + if sdk is None: + logger.info("Skipping RW subscription sync: RemnaWave is not configured") + return False + + rw_user = None + if telegram_id is not None: + rw_user = await get_user_by_telegram_id(sdk, telegram_id) + + if rw_user is None: + rw_username = _build_rw_username(telegram_id=telegram_id, username=username) + if rw_username is None: + logger.warning( + "Skipping RW subscription sync: cannot build username for telegram_id=%s username=%s", + telegram_id, + username, + ) + return False + + rw_user = await get_user_by_username(sdk, rw_username) + if rw_user is None: + rw_user = await create_user( + sdk=sdk, + username=rw_username, + telegram_id=telegram_id, + expire_at=expires_at, + hwid_device_limit=None if devices == 0 else devices, + squad_uuids=cfg.remnawave_default_squads, + ) + if rw_user is None: + logger.warning( + "Failed to create RW user for telegram_id=%s username=%s", + telegram_id, + username, + ) + return False + + expire_synced = await update_expire_at(sdk, rw_user.uuid, expires_at) + devices_synced = await set_hwid_limit(sdk, rw_user.uuid, devices) + return expire_synced and devices_synced diff --git a/requirements.txt b/requirements.txt index 692267d..47e494b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ pydantic-settings>=2.14.0 asyncpg>=0.31.0 alembic>=1.18.0 aiohttp>=3.14.0 -python-multipart==0.0.32 \ No newline at end of file +python-multipart==0.0.32 +remnawave>=2.6.1 \ No newline at end of file diff --git a/routes/orders.py b/routes/orders.py index 90046d2..b4b9edd 100644 --- a/routes/orders.py +++ b/routes/orders.py @@ -1,5 +1,5 @@ -from datetime import UTC, datetime import math +from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession @@ -61,12 +61,11 @@ async def checkout( amount=amount_to_pay, status=InvoiceStatus.ACTIVE, ) - with open("log.txt", "w") as f: - f.write( - " ".join((str(price), str(amount_to_pay), cfg.pally_shop_id, str(order_entry.id))) - ) + bill = await pally.bills.create( - float(amount_to_pay), cfg.pally_shop_id, order_id=str(order_entry.id) + amount=float(amount_to_pay), + shop_id=cfg.pally_shop_id, + order_id=str(order_entry.id), ) payment_link = bill.link_page_url if not payment_link: diff --git a/routes/payments/pally.py b/routes/payments/pally.py index cdba2de..24d4400 100644 --- a/routes/payments/pally.py +++ b/routes/payments/pally.py @@ -14,6 +14,7 @@ from core.deps import get_db from db.models.orders import OrderStatus from db.models.transactions import BalanceTransaction, BalanceTxType from external.pally import BillStatus +from external.rw import sync_subscription_by_telegram_id from repositories import AddonsRepository from repositories.invoices import InvoiceRepository from repositories.orders import OrderRepository @@ -248,7 +249,15 @@ async def pally_callback( # noqa: PLR0911, PLR0912, PLR0915 pricing=pricing, now=now, ): - await apply_order_now(session, user=user, order=order, pricing=pricing, now=now) + applied_subscription = await apply_order_now( + session, user=user, order=order, pricing=pricing, now=now + ) + await sync_subscription_by_telegram_id( + expires_at=applied_subscription.expires_at, + devices=applied_subscription.devices, + telegram_id=user.telegram_id, + username=user.username, + ) else: if subscription is None: logger.critical( diff --git a/send_pally_webhook.py b/send_pally_webhook.py new file mode 100644 index 0000000..0905aba --- /dev/null +++ b/send_pally_webhook.py @@ -0,0 +1,106 @@ +""" +Симулятор вебхука Pally для локального тестирования. + +Использование: + python tests/send_test_webhook.py --user-id 123456789 --amount 300 --bill-id 1 + +Предварительно: + - API должен быть запущен: uvicorn api.main:app --port 8000 + - Пользователь с --user-id должен существовать в БД + - Счёт с --bill-id должен существовать в БД и принадлежать этому пользователю +""" + +import argparse +import hashlib +import os + +import httpx +from dotenv import load_dotenv + +load_dotenv() + +ENDPOINT = "/payments/pally/result" + + +def calculate_signature( + out_sum: str, + inv_id: str, + pally_token: str, +) -> str: + raw = f"{out_sum}:{inv_id}:{pally_token}" + return hashlib.md5(raw.encode()).hexdigest().upper() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--user-id", type=int, required=True) + parser.add_argument("--amount", type=int, required=True) + parser.add_argument("--bill-id", type=int, required=True) + parser.add_argument("--host", default="http://127.0.0.1:8000") + parser.add_argument( + "--status", + default="SUCCESS", + choices=["SUCCESS", "FAIL", "UNDERPAID", "OVERPAID"], + help="Статус платежа (по умолчанию SUCCESS)", + ) + parser.add_argument( + "--customer-pays-fees", + action="store_true", + help="Симулировать сценарий 'клиент платит комиссию'", + ) + args = parser.parse_args() + + pally_token = os.environ["PALLY_TOKEN"] + + # ИСПРАВЛЕНО: InvId должен содержать ID счета из БД (это order_id при создании счета) + # Именно InvId используется в webhook handler для поиска счета в БД + inv_id = str(args.bill_id) # InvId = bill ID из нашей БД (order_id при создании) + trs_id = f"LXZv3R{args.bill_id}" # TrsId - уникальный идентификатор счета в Pally + currency = "RUB" + custom = None # custom field не используется в production (остается None) + + # Симуляция комиссии + if args.customer_pays_fees: + # Симулируем комиссию ~10% (как в вашем продакшене: 50 -> 55.4) + commission_rate = 0.108 # ~10.8% + gross_amount = args.amount * (1 + commission_rate) + commission = gross_amount - args.amount + + print("customer pays fee:") + print(f" Сумма заказа: {args.amount} RUB") + print(f" Комиссия: {commission:.2f} RUB") + print(f" Итого к оплате: {gross_amount:.2f} RUB") + print(f" К зачислению: {args.amount} RUB") + else: + gross_amount = args.amount + commission = 0.00 + + # Сигнатура рассчитывается с использованием InvId (который содержит bill ID) + signature = calculate_signature(f"{gross_amount:.2f}", inv_id, pally_token) + + # Используем правильные имена полей согласно документации pally.info + payload = { + "InvId": inv_id, # Содержит ID счета из БД + "OutSum": f"{gross_amount:.2f}", # Сумма, которую заплатил клиент (с комиссией) + "Commission": f"{commission:.2f}", + "TrsId": trs_id, + "Status": args.status, + "CurrencyIn": currency, + "custom": custom, # None в production + "SignatureValue": signature, + } + + # Добавляем BalanceAmount только если клиент платит комиссию + if args.customer_pays_fees: + payload["BalanceAmount"] = str(args.amount) # Сумма к зачислению (без комиссии) + payload["BalanceCurrency"] = currency + + print(f"{args.host}{ENDPOINT}") + print(f"Отправляю вебхук: {payload}\n") + response = httpx.post(f"{args.host}{ENDPOINT}", data=payload) + print(f"Статус ответа: {response.status_code}") + print(f"Тело ответа: {response.text}") + + +if __name__ == "__main__": + main() diff --git a/services/subscriptions.py b/services/subscriptions.py index 12a324c..2c009a4 100644 --- a/services/subscriptions.py +++ b/services/subscriptions.py @@ -6,16 +6,21 @@ from sqlalchemy.ext.asyncio import AsyncSession from db.models import Subscription, SubscriptionAddon, User from db.models.orders import Order, OrderStatus from db.models.transactions import BalanceTransaction, BalanceTxType +from external.rw import sync_subscription_by_telegram_id from schemas.enums import SubscriptionStatus from schemas.plans import PricingPlans -def calculate_plan_monthly_price(pricing: PricingPlans, devices: int, addon_ids: list[str]) -> float: +def calculate_plan_monthly_price( + pricing: PricingPlans, devices: int, addon_ids: list[str] +) -> float: addon_prices = {addon.id: addon.price for addon in pricing.addons} return devices * pricing.device_price + sum(addon_prices[addon_id] for addon_id in addon_ids) -def calculate_order_total(pricing: PricingPlans, devices: int, addon_ids: list[str], duration_days: int) -> float: +def calculate_order_total( + pricing: PricingPlans, devices: int, addon_ids: list[str], duration_days: int +) -> float: monthly_price = calculate_plan_monthly_price(pricing, devices, addon_ids) return monthly_price * duration_days / 30 @@ -29,10 +34,10 @@ def get_subscription_addon_ids(subscription: Subscription | None) -> list[str]: def should_apply_immediately( *, - subscription: Subscription | None, order: Order, pricing: PricingPlans, now: datetime, + subscription: Subscription | None = None, ) -> bool: if subscription is None or subscription.expires_at <= now: return True @@ -66,7 +71,9 @@ async def replace_subscription_addons( session.add(SubscriptionAddon(subscription_id=subscription_id, addon_id=addon_id)) -async def ensure_subscription(user: User, session: AsyncSession, starts_at: datetime) -> Subscription: +async def ensure_subscription( + user: User, session: AsyncSession, starts_at: datetime +) -> Subscription: subscription = user.subscription if subscription is not None: return subscription @@ -113,7 +120,7 @@ async def apply_order_now( order: Order, pricing: PricingPlans, now: datetime, -) -> None: +) -> Subscription: subscription = await ensure_subscription(user, session, now) addon_ids = [addon.addon_id for addon in order.addons] current_addons = [] if subscription.devices == 0 else get_subscription_addon_ids(subscription) @@ -142,11 +149,10 @@ async def apply_order_now( await replace_subscription_addons(session, subscription.id, addon_ids) order.applies_at = now order.applied_at = now + return subscription -async def queue_order_for_later( - *, order: Order, subscription: Subscription, now: datetime -) -> None: +async def queue_order_for_later(*, order: Order, subscription: Subscription, now: datetime) -> None: order.applies_at = max(subscription.expires_at, now) @@ -157,6 +163,7 @@ async def sync_user_subscription( ) -> None: now = datetime.now(UTC) subscription = user.subscription + applied_due_orders = False if subscription is not None and subscription.expires_at <= now: due_orders = [ @@ -181,5 +188,14 @@ async def sync_user_subscription( [addon.addon_id for addon in order.addons], ) order.applied_at = now + applied_due_orders = True else: subscription.status = SubscriptionStatus.EXPIRED + + if applied_due_orders: + await sync_subscription_by_telegram_id( + expires_at=subscription.expires_at, + devices=subscription.devices, + telegram_id=user.telegram_id, + username=user.username, + )