diff --git a/VERSION b/VERSION index 3b2dba6..3fa4ee1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+75 +0.1.0+76 diff --git a/bot.py b/bot.py index e19ede2..d5bede1 100644 --- a/bot.py +++ b/bot.py @@ -467,10 +467,10 @@ async def _show_subscription_info( if not sub: text = "Подписка закончилась" else: - days_left = user_store._days_until(sub) + active, value, unit = user_store.subscription_time_left(sub) text = ( - f"Подписка активна\nЗакончится через {days_left} дн." - if days_left > 0 else "Подписка закончилась" + f"Подписка активна\nЗакончится через {value} {unit}" + if active else "Подписка закончилась" ) if mode == "subscription": diff --git a/tests/test_keyboards.py b/tests/test_keyboards.py index 41c94fa..b35c239 100644 --- a/tests/test_keyboards.py +++ b/tests/test_keyboards.py @@ -244,7 +244,7 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase): async def test_subscription_status_uses_renew_button_after_previous_payment(self): message = _StubMessage() sub = {"last_paid_at": "2026-01-01T00:00:00", "expires_at": "2026-02-01T00:00:00", "is_active": 1} - with patch.object(bot.user_store, "_days_until", return_value=2): + with patch.object(bot.user_store, "subscription_time_left", return_value=(True, 2, "дн.")): await bot._show_subscription_info(cast(Any, message), cast(Any, sub)) markup = message.answers[0][1]["reply_markup"] self.assertIn("Подписка активна", message.answers[0][0]) diff --git a/user_store.py b/user_store.py index 02a4a27..b275ba9 100644 --- a/user_store.py +++ b/user_store.py @@ -153,33 +153,51 @@ SUBSCRIPTION_PLANS = { } +def _subscription_expires_at(sub: sqlite3.Row) -> datetime.datetime | None: + if not sub or sub["is_active"] == 0: + return None + + def utc(value: str) -> datetime.datetime: + parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is not None: + parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return parsed + + if sub["last_paid_at"] is None: + return utc(sub["trial_started_at"]) + datetime.timedelta(days=TRIAL_DAYS) + return utc(sub["last_paid_at"]) + + def _days_until(sub: sqlite3.Row, now: datetime.datetime | None = None) -> int: """Сколько дней осталось от оплаты (триал или подписка). Отрицательно = истекло/отключено.""" if not sub: return -1 if sub["is_active"] == 0: return -1 - # SQLite CURRENT_TIMESTAMP возвращает UTC-нейтральный datetime. - def utc(value: str) -> datetime.datetime: - return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) - - if sub["last_paid_at"] is None: - # Триал: trial_started_at + 3 дня - trial_start = utc(sub["trial_started_at"]) - if trial_start.tzinfo is not None: - trial_start = trial_start.astimezone(datetime.timezone.utc).replace(tzinfo=None) - expires = trial_start + datetime.timedelta(days=TRIAL_DAYS) - else: - expires = utc(sub["last_paid_at"]) - if expires.tzinfo is not None: - expires = expires.astimezone(datetime.timezone.utc).replace(tzinfo=None) + expires = _subscription_expires_at(sub) + if expires is None: + return -1 now = now or datetime.datetime.now(datetime.timezone.utc) - now = now.replace(tzinfo=None) + now = now.astimezone(datetime.timezone.utc).replace(tzinfo=None) if now.tzinfo else now delta = expires - now - # Полные дни, пока expires > now. При истечении (expires <= now) считаем -1. - return delta.days if expires > now else -1 + return delta.days if delta.total_seconds() > 0 else -1 +def subscription_time_left(sub: sqlite3.Row, now: datetime.datetime | None = None) -> tuple[bool, int, str]: + expires = _subscription_expires_at(sub) + if expires is None: + return False, 0, "" + now = now or datetime.datetime.now(datetime.timezone.utc) + now = now.astimezone(datetime.timezone.utc).replace(tzinfo=None) if now.tzinfo else now + seconds = int((expires - now).total_seconds()) + if seconds <= 0: + return False, 0, "" + if seconds >= 86400: + return True, seconds // 86400, "дн." + if seconds >= 3600: + return True, seconds // 3600, "ч." + return True, max(1, seconds // 60), "мин." + def subscription_row(user_id: int, db_path: str | None = None) -> sqlite3.Row | None: with closing(connect(db_path)) as connection: return connection.execute(