diff --git a/Subscription.md b/Subscription.md new file mode 100644 index 0000000..78e44f8 --- /dev/null +++ b/Subscription.md @@ -0,0 +1,111 @@ +# Подписка + +## Модель + +**Триал 3 дня → подписка 30 дней за 100 звёзд (Telegram Stars).** + +Константы в `user_store.py`: +```python +TRIAL_DAYS = 3 +SUBSCRIPTION_MONTH_DAYS = 30 +SUBSCRIPTION_PRICE_STARS = 100 +``` + +--- + +## Таблица `subscriptions` + +| Колонка | Смысл | +|---|---| +| `telegram_user_id` | PK, id пользователя | +| `trial_started_at` | момент создания (SQLite `CURRENT_TIMESTAMP`, UTC-нейтральный) | +| `last_paid_at` | момент/конец оплаченного периода; `NULL` = ещё триал | +| `is_active` | 1 — активна, 0 — отключена | +| `monthly_days` | длительность оплаченного периода (30) | +| `updated_at` | время последнего изменения | + +--- + +## Жизненный цикл + +1. **`/start`** → `ensure_subscription` → триал 3 дня, `is_active = 1`. +2. Генерация постов и идей доступна, пока `_days_until > 0`. +3. Триал/подписка кончилась → `_check_subscription` блокирует генерацию, показывает статус и предлагает оплатить. +4. **`/subscribe`** → `sendInvoice` (валюта `XTR`, 100 звёзд) → `pre_checkout_query` → `successful_payment` → `activate_subscription(months=1)` → +30 дней от момента оплаты, `is_active = 1`. +5. **`/clear`** очищает данные обучения и историю; **подписка и триал не меняются**. + +**Расчёт дней (`_days_until`):** +- Триал: `trial_started_at + TRIAL_DAYS` +- Подписка: `last_paid_at` +- Возвращает полные дни; если `is_active = 0` или срок истёк — `-1` + +--- + +## Реальная оплата Stars + +Оплата **настоящая** — через Telegram Stars (`payments.py`): + +- `/subscribe` и кнопка «Продлить подписку» выставляют инвойс (`sendInvoice`, валюта `XTR`, `provider_token` пустой — цифровой товар). +- `pre_checkout_query` подтверждает **только свой** инвойс на 100 звёзд в XTR, остальное отклоняет. +- Подписка активируется **только** в `successful_payment` — сам `/subscribe` её не продлевает. +- `telegram_payment_charge_id` каждого платежа пишется в таблицу `payments`, поэтому один платёж не может активировать подписку дважды; по этому же id делается возврат через `refundStarPayment`. + +### Dev-подтверждение +При точном `DEPLOY_ENV=development` после обычного сообщения об инвойсе отдельным сообщением показывается кнопка «🧪 Оплатил (dev)». Она включает доступ на 30 дней без реального платежа и доступна только пользователю, которому выставлен счёт. В production кнопка и callback не выдают доступ (fail-closed). + +--- + +## Что доступно без подписки + +| Команда / действие | Доступно | +|---|---| +| `/start`, `/help`, `/balance`, `/subscribe` | ✅ | +| Переходы между режимами | ✅ | +| Обучение (скриншоты, текст, голос) | ✅ | +| Генерация поста | ❌ | +| «✍️ Придумать пост» / `/post` | ❌ | +| `/idea` | ❌ | +| Пост по изображению | ❌ | + +Логика: блокирующая проверка стоит внутри `_classify_and_respond` и `_generate_post_from_training`, до вызова модели. + +--- + +## Сообщения + +**Вход в режим «Подписка»** показывает тип активного периода (триал/подписка) и остаток дней, плюс inline-кнопку «Продлить подписку», которая сразу открывает Stars-инвойс. + +**`/balance`** — тот же статус в виде команды: триал активен (сколько дней осталось) / подписка активна (сколько дней) / период истёк. + +**При истёкшем периоде:** +> Твой период (триал или подписка) истёк. Оплати подписку, чтобы продолжить. Стоимость — 100 звёзд в месяц. + +--- + +## Часовые пояса + +SQLite `CURRENT_TIMESTAMP` возвращает UTC **без** часового пояса. Код приводит все значения к naive UTC перед сравнением: + +```python +def utc(value: str) -> datetime.datetime: + dt = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + if dt.tzinfo is not None: + return dt.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return dt +``` + +Это убирает сдвиги при сравнении `expires` и `now`. + +--- + +## Тесты + +`SubscriptionTests` в `tests/test_writer_features.py`: +1. таблица `subscriptions` создаётся +2. `/start` создаёт триал +3. оплата активирует 30 дней (`successful_payment`) +4. `/balance` показывает статус +5. генерация блокируется при истёкшей подписке +6. отмена продления не активирует подписку +7. `/clear` сохраняет подписку и триал +8. dev-подтверждение оплаты работает только в development