diff --git a/cmd/app/main.go b/cmd/app/main.go index 6a1ffe6..424d23c 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -26,6 +26,7 @@ import ( "simpleAI/internal/budget" "simpleAI/internal/core" "simpleAI/internal/db" + "simpleAI/internal/envelopepush" "simpleAI/internal/mail" mcpserver "simpleAI/internal/mcp" "simpleAI/internal/notify" @@ -84,7 +85,7 @@ func main() { wg.Add(1) go func() { defer wg.Done() - if err := runTelegram(ctx, cfg, logger, llmClient, registry, tracer, obsTracer, budgetSkill); err != nil { + if err := runTelegram(ctx, cfg, logger, llmClient, registry, tracer, obsTracer, budgetSkill, budget.NewStore(pool)); err != nil { logger.Error("telegram stopped", "err", err) } }() @@ -117,6 +118,13 @@ func main() { tg := notify.NewTelegram(cfg.Telegram.Token, "") digest := budgetskill.NewDigestProvider(budgetStore) worker := notify.NewReminderWorker(budgetStore, tg, digest, logger) + // Утренние конверты: тело печатает тот же safe_to_spend, что отвечает на + // «сколько в конвертах» — формат живёт в скилле, воркер его не дублирует. + if skill, ok := registry.Get("safe_to_spend"); ok { + worker = worker.WithEnvelopes(envelopepush.New(budgetStore, skill)) + } else { + logger.Warn("safe_to_spend skill not registered: morning envelope push disabled") + } logger.Info("reminder worker started") worker.Run(ctx) }() @@ -147,7 +155,7 @@ func main() { logger.Info("shutdown complete") } -func runTelegram(ctx context.Context, cfg config.Config, logger *slog.Logger, llmClient core.LLMClient, registry *plugin.Registry, tracer *trace.Store, obsTracer *observability.Tracer, budgetSkill telegram.BudgetCallbackSkill) error { +func runTelegram(ctx context.Context, cfg config.Config, logger *slog.Logger, llmClient core.LLMClient, registry *plugin.Registry, tracer *trace.Store, obsTracer *observability.Tracer, budgetSkill telegram.BudgetCallbackSkill, envelopeReminders notify.EnvelopeReminderStore) error { if cfg.Telegram.Token == "" { logger.Error("telegram bot token is empty, skipping") return nil @@ -226,6 +234,8 @@ func runTelegram(ctx context.Context, cfg config.Config, logger *slog.Logger, ll RequestID: uuid.NewString(), MediaDir: cfg.Telegram.MediaDir, Registry: registry, + + EnvelopeReminders: envelopeReminders, }) if err != nil { logger.Error("telegram update error", "err", err) diff --git a/docs/adr/008-envelope-shares.md b/docs/adr/008-envelope-shares.md new file mode 100644 index 0000000..bd72131 --- /dev/null +++ b/docs/adr/008-envelope-shares.md @@ -0,0 +1,300 @@ +# ADR-008: Категорийные конверты — доли прихода (envelope shares) + +- **Статус:** Accepted +- **Дата:** 2026-08-23 +- **Контекст эпика:** simpleAI-faeq +- **Заменяет / дополняет:** ADR-007 (safe-to-spend, живой конверт). Инварианты ADR-007 §4 (недвойной учёт) действуют без изменений. + +## Контекст + +ADR-007 ввёл «живой конверт» (`budget_envelope`): приход + горизонт, остаток не хранится, а +считается из фактических транзакций. Конверт один и целиком: он отвечает на вопрос «сколько +свободно осталось», но не на вопрос «сколько из этого на еду, сколько на транспорт». + +Оператор хочет раскладывать приход по категорийным долям («еда», «транспорт», «развлечения»), +получать по ним остатки и уметь словами поправить одну долю. Лимиты долей должна назначать +система по истории трат, а не человек вручную. + +## Решение + +### 1. Именование: `share`, не `Bucket` + +`Bucket` / `GroupByBuckets` в `internal/skills/budget/buckets.go` — уже занятый концепт: +группировка категорий для дайджеста (Обязательства / Цели / Жизнь и радость). Он **не трогается**. + +Новый концепт называется **share** — доля прихода: + +| Слой | Имя | +|---|---| +| Таблица долей | `budget_envelope_share` | +| Таблица категорий доли | `budget_envelope_share_category` | +| Таблица ручных правок лимита | `budget_envelope_limit_override` | +| Go-тип доли | `EnvelopeShare` | +| Go-тип правки | `EnvelopeOverride` | + +Пользователю в ответах бота слово «share» не показывается — там остаётся «конверт». +Разделение только на уровне кода и схемы. + +### 2. Лимиты назначает система, человек их правит точечно + +Базовый лимит доли — **прогноз по переменным потребительским категориям** на длину горизонта +конверта. Источник истины для «что считать переменной ежедневной тратой» — существующая +`budget.IsVariableDailyExpense` (`internal/budget/categories.go`): потребительская И не +фиксированная. Основа расчёта — уже работающая `buildForecastBreakdown` +(`internal/skills/safetospend/compute.go:24`), она агрегирует прогноз по категориям +пропорционально `days/30`. Новая раскладка переиспользует её, а не заводит второй прогноз. + +Оператор правит **отдельную долю словами** («на еду хватит 15000»). Правка сохраняется как +запись в `budget_envelope_limit_override` и **переживает пересчёт**: при перераскладке +базовый прогноз пересчитывается, override применяется поверх. + +### 3. Делится `FreeAfterObligations`, не сырой приход + +Раскладывается `FreeAfterObligations` из `computeSafeToSpend` +(`internal/skills/safetospend/compute.go`), то есть приход **после** вычета регулярных +платежей, долгов и плановых трат. Раскладывать сырой приход нельзя — доли бы конкурировали +с уже известными обязательствами и суммарно обещали больше денег, чем есть. + +### 4. Инвариант сходимости — ТОЛЬКО на `allocated` + +``` +Σ allocated + свободно = FreeAfterObligations +``` + +`carried_in` в этот инвариант **не входит**. + +Привязывать инвариант к существующему `RemainingTHB` (`computeRemaining`) **нельзя**: разница +между `Σ allocated` и `RemainingTHB` состоит из трёх независимых членов — + +``` +RemainingTHB − Σ allocated = −Σ carried_in + факт_фикс + факт_переменные_recurring +``` + +то есть перенос с прошлого конверта, факт по фиксированным категориям и факт по переменным +категориям, порождённый recurring-платежами. Ни один из них не является ошибкой раскладки, +поэтому равенство на `RemainingTHB` не сойдётся никогда и как инвариант бесполезно. + +В ответе бота эти члены показываются явно, отдельными строками: + +- **«вне конвертов»** = факт по фиксированным категориям + факт по переменным recurring; +- **«перенесено с прошлого раза»** = `carried_in`, своей строкой. + +### 5. Recurring исключается из факта доли + +Транзакции с `recurring_id IS NOT NULL` **не попадают** в факт по доле: они уже вычтены как +обязательства на этапе `computeSafeToSpend`, повторный учёт — двойной (ADR-007 §4). + +Механизм тот же, что уже применён для дохода в `GetRegularMonthlyIncomeAvg` +(`internal/budget/store.go:855`): фильтр по `recurring_id` прямо в SQL-агрегате. + +`consumptionSpentTHB` в `internal/skills/safetospend/remaining.go` (точнее — в `compute.go:123`, +вызывается из `remaining.go:47`) **не трогаем**: она обслуживает старый режим остатка ADR-007, +у неё своя семантика, и её правка сломала бы уже принятый оператором ответ. + +### 6. Ключ категории: id + нормализованное имя, fallback обязателен + +`budget_envelope_share_category` хранит **оба** ключа: + +- `category_id UUID NULL` — FK на `budget_category`; +- `category_name TEXT NOT NULL` — в нижнем регистре. + +Матчинг траты к доле: по `category_id`, если он есть; иначе по `lower(name)`. + +Почему оба. Есть два класса транзакций, которые по id не матчатся: + +1. транзакции с `category_id IS NULL` (`budget_transaction.category_id` — nullable, при удалении + категории `ON DELETE SET NULL`); +2. регистровые дубли категорий: `FindCategoryByName` ищет по `LOWER(name)`, а уникальный индекс + `idx_budget_category_name_type` — по `(name, type)` и регистрозависим, поэтому в таблице + могут сосуществовать «Еда» и «еда» как разные строки с разными id. + +Отсюда третье требование: **fallback-доля «прочее» обязательна всегда**. Всё, что не +смэтчилось ни по id, ни по имени, падает в неё. Без fallback часть факта потерялась бы молча. + +### 7. Валюта: THB — единая расчётная + +`allocated` и `carried_in` хранятся в THB, единой расчётной валюте проекта (как и весь +safe-to-spend). Приход конвертируется в THB **один раз**, при раскладке. Дальше пересчётов +курса нет — иначе остаток доли дрейфовал бы от движения курса без единой траты. + +### 8. Остаток доли не хранится + +``` +remaining = allocated + carried_in − факт по категориям доли за период конверта +``` + +Хранение остатка запрещено — это инвариант недвойного учёта ADR-007 §4 в его структурном +виде: у каждого слагаемого ровно один источник, остаток всегда производен. + +### 9. Carry-over по имени доли + +- `kind = 'save'` — остаток **переносится** в новый конверт, ключ переноса — имя доли. + Отсюда `UNIQUE(envelope_id, name)`: без него перенос по имени неоднозначен. +- `kind = 'spend'` — остаток **сгорает**. +- Доля с `kind='save'`, исчезнувшая из новой раскладки, всё равно создаётся: `allocated = 0`, + `carried_in = остаток`. Иначе накопленное молча пропало бы вместе с долей. + +### 10. Закрытие конверта: `period_end := now − 1 день` + +При переключении на новый конверт старый закрывается как + +``` +period_end := now − 1 день +active := false +``` + +Причина: все даты в схеме — `DATE`, а `periodSnapshotQuery` (`internal/budget/store.go:1273`) +фильтрует границы **включительно** (`>= period_start AND <= period_end`). При +`period_end := now` траты дня переключения попали бы одновременно в оба конверта — в старый по +верхней границе и в новый по нижней. + +Если `period_end` при этом уходит раньше `period_start` (конверт заведён и закрыт в один день) — +обрезать до `period_start`, чтобы не получить период отрицательной длины (`GetPeriodSnapshot` +на таком возвращает ошибку). + +### 11. Числа не проходят через LLM + +Раскладка и остатки — **чистые функции** на Go. LLM только нарративит готовый результат. +Это прямое продолжение ADR-007: `computeSafeToSpend` / `computeRemaining` уже так устроены. + +## Известное ограничение + +У `budget_transaction` нет `chat_id` — факт по тратам глобальный, а конверт chat-scoped +(ADR-004). Значит доли считают факт по всем чатам сразу. + +Скоуп сознательно **не расширяем**: у бота один пользователь, добавление `chat_id` в +транзакции — отдельная миграция с бэкфиллом всей истории и правкой всех агрегатов +(`advisorSnapshotQuery`, `periodSnapshotQuery`, forecast, digest). Цена не оправдана. +Ограничение фиксируется здесь, чтобы следующий агент не принял его за баг. + +## Контракт роутинга (для golden-set) + +| Сценарий оператора | skill | action | +|---|---|---| +| «пришло 127000, разложи по конвертам» | `budget` | `start_envelope` | +| «сколько в конвертах», «сколько осталось на еду» | `safe_to_spend` | — (read-only) | +| «на еду хватит 15000» | `budget` | `set_share_limit` | +| «купил еды на 3000» | `budget` | `add_expense` | + +Последняя строка — не формальность: разграничение «правка лимита» vs «запись траты» уже +ломалось (simpleAI-399, simpleAI-q49). Обе фразы содержат категорию и сумму, различает их +только модальность. Кейс держится в golden-set как регрессионный. + +Сценарии зафиксированы в `evals/golden_set.jsonl` (`r043`–`r047`) **до** кода. + +**Где на самом деле живёт триггер.** Не весь роутинг задаётся Manifest'ом: в +`buildToolsSystemPrompt` (`internal/agent/router.go:18`) есть блок `ROUTING RULES` с пометкой +«приоритет выше описаний инструментов», и правило №2 хардкодит «купил / потратил / заплатил → +budget.add_expense». Проверено мутацией: вырезание триггеров `add_expense` из Manifest'а +budget-скилла **не** меняет маршрут для «купил кофе 200» — решает хардкод в роутере; а +вырезание `add_planned_expense` (триггер живёт только в Manifest'е) роняет кейс `r040` сразу. + +Следствие для задач 6/8: разграничение `set_share_limit` vs `add_expense` («на еду хватит +15000» vs «купил еды на 3000») нельзя чинить одной фразой в Manifest'е — оно попадает в зону +действия `ROUTING RULES`, и правку надо делать там же, иначе она будет перебита. + +## Поправка 2026-08-24 (simpleAI-faeq.10 / faeq.11) + +Прогон на реплике и разбор оператором заставили изменить три решения выше. + +### §3 отменён: делится приход, а обязательства — видимые конверты + +`FreeAfterObligations` как «то, что делим» сохраняется, но обязательства больше +не исчезают из ответа. Каждый регулярный платёж из `budget_recurring` становится +долей `kind='fixed'` — со своим именем, суммой и датой платежа (`due_date`). + +Причина — прямая: оператор отверг деление трат на «обязательные» и «на жизнь» +(«есть мне тоже надо, или ты считаешь что еда необязательна?»), а сводная строка +«обязательства 12332» ломала сходимость: приход визуально не сходился, и было +непонятно, куда делись деньги. Ресёрч подтверждает: у YNAB обязательные платежи — +первые КАТЕГОРИИ плана, а не вычет до раскладки. + +Окно финансирования fixed-долей — месяц вперёд от начала периода, а НЕ период +конверта: аренда платится 10.09 при периоде до 06.09, но отложить надо сейчас +(sinking fund). Категорий у fixed-доли нет: её факт — сам recurring-платёж, а +транзакции с `recurring_id` в факт долей не попадают (§5 без изменений). + +### §4: строка «вне конвертов» убрана + +Она показывала 0 ฿ при аренде 18 000 и уборке 2 500 за период, потому что считала +ФАКТ прошедших трат, а читалась как «что ещё предстоит». Объяснять расхождение +отдельной сводкой больше нечем: каждый предстоящий платёж стоит своей строкой. + +Инвариант §4 усилен и распространён на перенос: + +``` +Σ allocated + Σ carried_in ≤ приход + реальный остаток прошлого периода +``` + +### §9: конверт, не проживший ни дня, переносит только свой carried_in + +Повторная раскладка того же прихода в тот же день наращивала перенос +0 → 26681 → 53362 → 65335 THB. Конверт, закрытый в день создания (период нулевой +длины по §10), не прожил ничего: его `allocated` профинансирован тем же приходом, +который раскладывается заново, и переносить его — печатать деньги. Переносится +только его собственный `carried_in`. + +Известное ограничение: второй НАСТОЯЩИЙ приход в тот же день неотличим от повтора +раскладки. Выбор сделан в пользу невозможности напечатать деньги. + +### Новое: дневной лимит + +``` +лимит дня = (Σ гибких конвертов − потрачено) / оставшиеся дни +``` + +Приход в формуле не участвует — поэтому лимит работает и при приходе в 10 рублей, +и при нулевом. `fixed` и `save` в числитель не входят. Пересчитывается при каждом +показе остатков, то есть после каждой траты. Это зеркало, а не запрет. + +### Новое: планка мелочи абсолютная + +Порог схлопывания категории в «прочее» был долей свободных денег и двигался вместе +с обязательствами — «Транспорт 985» перепрыгивал его туда-обратно между +раскладками. Стал абсолютным (`minShareMonthlyTHB`, пропорционально длине периода): +набор конвертов — свойство трат человека, а не остатка после обязательств. + +### Формат ответа + +Обычный текст + ОДИН моноблок шириной 32 знака (порог 36 из ресёрча вёрстки +Telegram), две с половиной колонки: метка ≤18, дата платежа, сумма по правому +краю. Эмодзи внутри `pre` запрещены — ломают выравнивание. Разряды через пробел, +без копеек. Предупреждения «мало данных» свёрнуты в одну строку: одиннадцать строк +подряд прятали сам ответ. + +### Разметка Telegram: `parse_mode=HTML` (решено, simpleAI-faeq.14) + +Открытый вопрос «каким `parse_mode` слать» закрыт в пользу **HTML**, не MarkdownV2. + +Причина — поверхность риска, а не вкус. Ошибка экранирования в Telegram не +портит вид сообщения, а роняет отправку целиком: API отвечает 400, и пользователь +не получает НИЧЕГО. В MarkdownV2 экранирования требуют восемнадцать символов, +включая точку и минус, — они есть буквально в каждом сообщении с датой и суммой, +и часть текста пишет LLM, то есть перечислить источники символов заранее нельзя. +В HTML опасных символов три: `&`, `<`, `>`. Обе нужные конструкции — `
` для +моноблока и `` для заголовков — покрыты. + +Как устроено (`internal/bot/format`): + +- Скиллы продолжают писать в ограниченной разметке (моноблок в тройных кавычках, + `**жирный**`). Это **структура намерения**, а не готовый Markdown. +- Рендер переводит её в HTML, экранируя ВСЁ остальное. Постфактум-экранирование + готовой разметки отвергнуто: оно требует отличать «звёздочку-разметку» от + «звёздочки из текста LLM» — ровно тот класс ошибки, который роняет отправку. +- Непарный ограничитель и непарные `**` остаются литералом: незакрытый `` — + это 400. +- Длинный текст режется по лимиту Telegram, разрез закрывает и переоткрывает ``. +- Если Telegram всё же ответил ошибкой на размеченный вариант и не ушло ещё + ничего — тот же текст уходит простым. Молчание бота хуже звёздочек в чате. + +Разметка включается в ЕДИНСТВЕННОЙ точке отправки каждого канала — в адаптере +(`internal/adapters/telegram`) и в пуше (`internal/notify/telegram.go`). Оба +канала везут одну и ту же раскладку конвертов; починка одного из них оставила бы +утренний пуш без разметки. + +## Последствия + +- Схема растёт на три таблицы; миграции — вперёд, без правки `00016_budget_envelope.sql`. +- `budget_envelope` получает доли; старый режим остатка ADR-007 продолжает работать как был. +- Новый action `budget.set_share_limit` расширяет манифест budget-скилла — а значит меняет + `prompt_hash` в eval-прогонах. Регрессию по существующим кейсам ловит golden-set. diff --git a/evals/golden_set.jsonl b/evals/golden_set.jsonl index f3fdeb3..741db91 100644 --- a/evals/golden_set.jsonl +++ b/evals/golden_set.jsonl @@ -42,3 +42,16 @@ {"id":"r040","input":"запланируй трату 15600 на визу","expected":{"skill":"budget","action":"add_planned_expense"},"tags":["add_planned_expense","planned","ru"]} {"id":"r041","input":"запомни приход 127000 на 2 недели","expected":{"skill":"budget","action":"start_envelope"},"tags":["start_envelope","envelope","ru"]} {"id":"r042","input":"сколько свободных денег осталось?","expected":{"skill":"safe_to_spend","action":""},"tags":["remaining","envelope","ru"]} +# ADR-008 (конверты-доли). Кейсы заведены ДО кода: скиллов/actions ещё нет, +# до задач 4/6/8 эпика simpleAI-faeq они краснеют — это ожидаемый TDD-красный. +# Механизма skip у харнеса нет; срез снимается тегом: -tag envelope_shares. +{"id":"r043","input":"пришло 127000, разложи по конвертам","expected":{"skill":"budget","action":"start_envelope"},"tags":["envelope_shares","allocate","income_arrived","ru"],"notes":"ADR-008 §1 — раскладка прихода по категорийным долям; делится FreeAfterObligations, не сырой приход"} +{"id":"r044","input":"сколько в конвертах","expected":{"skill":"safe_to_spend","action":""},"tags":["envelope_shares","share_status","read_only","ru","pending_impl"],"notes":"ADR-008 — read-only статус долей, ничего не пишет"} +{"id":"r045","input":"сколько осталось на еду","expected":{"skill":"safe_to_spend","action":""},"tags":["envelope_shares","share_status","read_only","ru"],"notes":"ADR-008 §8 — остаток доли производный: allocated + carried_in − факт"} +{"id":"r046","input":"на еду хватит 15000","expected":{"skill":"budget","action":"set_share_limit"},"tags":["envelope_shares","share_override","ru","regression"],"notes":"ADR-008 §2 — ручная правка лимита доли (override), НЕ add_expense: сумма есть, но траты не было"} +{"id":"r047","input":"купил еды на 3000","expected":{"skill":"budget","action":"add_expense"},"tags":["envelope_shares","past_tense","with_amount","ru","regression"],"notes":"контраст к r046: разграничение триггеров уже ломалось (simpleAI-399, simpleAI-q49). Должен проходить и ДО реализации конвертов"} +{"id":"r048","input":"покажи конверты в рублях","expected":{"skill":"safe_to_spend","action":""},"tags":["envelope_shares","share_status","display_currency","read_only","ru"],"notes":"валюта показа: конверты по умолчанию в батах, «в рублях» — просьба перевести показ (display_currency=RUB), скилл прежний"} +{"id":"r049","input":"на еду хватит 5000 бат","expected":{"skill":"budget","action":"set_share_limit"},"tags":["envelope_shares","share_override","display_currency","ru"],"notes":"лимит словами в батах: тот же set_share_limit, что и r046 в рублях; валюта суммы идёт в currency=THB"} +{"id":"r050","input":"пришло 127000 рублей, разложи по конвертам","expected":{"skill":"budget","action":"start_envelope"},"tags":["envelope_shares","allocate","income_arrived","acceptance","ru"],"notes":"simpleAI-faeq.10 — эталонный кейс приёмки: 127к делятся на ВСЕ расходы периода, включая регулярные платежи отдельными строками; повтор той же фразы обязан давать тот же результат (carried_in не растёт)"} +{"id":"r051","input":"пришло 10 рублей, разложи по конвертам","expected":{"skill":"budget","action":"start_envelope"},"tags":["envelope_shares","allocate","small_income","ru"],"notes":"simpleAI-faeq.11 §5 — малый приход не ломает раскладку: дневной лимит считается от гибких конвертов, приход в формуле не участвует"} +{"id":"r052","input":"сколько можно тратить в день","expected":{"skill":"safe_to_spend","action":""},"tags":["envelope_shares","daily_limit","read_only","ru"],"notes":"simpleAI-faeq.11 §5 — дневной лимит = гибкие конверты / оставшиеся дни, пересчитывается после каждой траты; read-only"} diff --git a/internal/adapters/telegram/adapter.go b/internal/adapters/telegram/adapter.go index 15727e4..7ab1911 100644 --- a/internal/adapters/telegram/adapter.go +++ b/internal/adapters/telegram/adapter.go @@ -11,6 +11,7 @@ import ( tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + botformat "simpleAI/internal/bot/format" "simpleAI/internal/core" ) @@ -96,24 +97,64 @@ func (a *Adapter) Updates(ctx context.Context) (<-chan core.Update, error) { } func (a *Adapter) Send(ctx context.Context, chatID int64, text string) error { - if ctx != nil && ctx.Err() != nil { - return ctx.Err() - } - msg := tgbotapi.NewMessage(chatID, text) - _, err := a.bot.Send(msg) - return err + return a.sendMessage(ctx, chatID, 0, text, nil) } func (a *Adapter) Reply(ctx context.Context, chatID int64, replyTo int, text string) error { + return a.sendMessage(ctx, chatID, replyTo, text, nil) +} + +// sendMessage — единственная точка отправки текста. Здесь и только здесь +// включается parse_mode: разметка обязана быть централизованной, иначе один +// забытый вызов роняет формат (или, хуже, отправку) уже после ревью. +// +// Порядок такой: +// 1. текст скилла переводится в HTML (экранируется ВСЁ, кроме моноблока и +// **жирного** — см. internal/bot/format); +// 2. режется на куски в пределах лимита Telegram; +// 3. если Telegram всё же ответил ошибкой на размеченный вариант — тот же +// текст уходит простым. Молчание бота хуже, чем звёздочки в чате. +// +// Клавиатура вешается на последний кусок: выше неё должен лежать весь текст, +// к которому она относится. +func (a *Adapter) sendMessage(ctx context.Context, chatID int64, replyTo int, text string, rows [][]core.Button) error { if ctx != nil && ctx.Err() != nil { return ctx.Err() } - msg := tgbotapi.NewMessage(chatID, text) - msg.ReplyToMessageID = replyTo - _, err := a.bot.Send(msg) + chunks := botformat.MessagesHTML(text) + if len(chunks) == 0 { + return nil + } + sent, err := a.deliver(chatID, replyTo, chunks, tgbotapi.ModeHTML, rows) + if err == nil { + return nil + } + // Фоллбэк — только если не ушло НИЧЕГО. Иначе повтор простым текстом + // продублировал бы в чате уже доставленные куски. + if sent > 0 { + return err + } + _, err = a.deliver(chatID, replyTo, botformat.MessagesPlain(text), "", rows) return err } +func (a *Adapter) deliver(chatID int64, replyTo int, chunks []string, parseMode string, rows [][]core.Button) (int, error) { + for i, chunk := range chunks { + msg := tgbotapi.NewMessage(chatID, chunk) + msg.ParseMode = parseMode + if replyTo != 0 && i == 0 { + msg.ReplyToMessageID = replyTo + } + if len(rows) > 0 && i == len(chunks)-1 { + msg.ReplyMarkup = toInlineKeyboard(rows) + } + if _, err := a.bot.Send(msg); err != nil { + return i, err + } + } + return len(chunks), nil +} + func (a *Adapter) SendTyping(ctx context.Context, chatID int64) error { if ctx != nil && ctx.Err() != nil { return ctx.Err() @@ -126,21 +167,50 @@ func (a *Adapter) SendWithButtons(ctx context.Context, chatID int64, text string if ctx != nil && ctx.Err() != nil { return ctx.Err() } - msg := tgbotapi.NewMessage(chatID, text) - msg.ReplyMarkup = toInlineKeyboard(rows) - _, err := a.bot.Send(msg) - return err + return a.sendMessage(ctx, chatID, 0, text, rows) } func (a *Adapter) EditWithButtons(ctx context.Context, chatID int64, messageID int, text string, rows [][]core.Button) error { if ctx != nil && ctx.Err() != nil { return ctx.Err() } - edit := tgbotapi.NewEditMessageText(chatID, messageID, text) + // Правку резать некуда — сообщение одно. Слишком длинный текст всё же + // усекается по лимиту: 400 «message is too long» стёр бы правку целиком. + edit := tgbotapi.NewEditMessageText(chatID, messageID, firstChunk(botformat.MessagesHTML(text))) + edit.ParseMode = tgbotapi.ModeHTML + edit.ReplyMarkup = editKeyboard(rows) + if _, err := a.bot.Send(edit); err != nil { + // Тот же фоллбэк, что и у отправки: разметка не должна стирать содержимое. + plain := tgbotapi.NewEditMessageText(chatID, messageID, firstChunk(botformat.MessagesPlain(text))) + plain.ReplyMarkup = editKeyboard(rows) + _, err := a.bot.Send(plain) + return err + } + return nil +} + +// editKeyboard решает, что приложить к правке сообщения: +// - rows == nil — клавиатуру не трогаем (у сообщения её и не было); +// - rows пуст, но не nil — ПУСТАЯ клавиатура, то есть «снять кнопки»; +// ровно так вызывающий код гасит инлайн-кнопки после обработки callback-а, +// и молчаливый пропуск reply_markup оставил бы их живыми; +// - иначе — обычная клавиатура. +func editKeyboard(rows [][]core.Button) *tgbotapi.InlineKeyboardMarkup { + if rows == nil { + return nil + } kb := toInlineKeyboard(rows) - edit.ReplyMarkup = &kb - _, err := a.bot.Send(edit) - return err + if kb.InlineKeyboard == nil { + kb.InlineKeyboard = [][]tgbotapi.InlineKeyboardButton{} + } + return &kb +} + +func firstChunk(chunks []string) string { + if len(chunks) == 0 { + return "" + } + return chunks[0] } func (a *Adapter) AnswerCallback(ctx context.Context, callbackID string) error { diff --git a/internal/adapters/telegram/adapter_test.go b/internal/adapters/telegram/adapter_test.go new file mode 100644 index 0000000..ce3add1 --- /dev/null +++ b/internal/adapters/telegram/adapter_test.go @@ -0,0 +1,282 @@ +package telegram + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "simpleAI/internal/core" +) + +// fakeTelegram — подставной api.telegram.org: пишет все sendMessage-вызовы и +// умеет отвечать 400, как настоящий Telegram на кривой разметке. +type fakeTelegram struct { + mu sync.Mutex + calls []map[string]string + failOn func(call map[string]string) bool + srv *httptest.Server +} + +func newFakeTelegram(t *testing.T) *fakeTelegram { + t.Helper() + f := &fakeTelegram{} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("parse form: %v", err) + } + call := map[string]string{"method": r.URL.Path} + for k := range r.Form { + call[k] = r.Form.Get(k) + } + if strings.HasSuffix(r.URL.Path, "/getMe") { + writeJSON(w, http.StatusOK, `{"ok":true,"result":{"id":1,"is_bot":true,"username":"t"}}`) + return + } + f.mu.Lock() + f.calls = append(f.calls, call) + fail := f.failOn != nil && f.failOn(call) + f.mu.Unlock() + if fail { + writeJSON(w, http.StatusBadRequest, `{"ok":false,"error_code":400,"description":"can't parse entities"}`) + return + } + writeJSON(w, http.StatusOK, `{"ok":true,"result":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"}}}`) + })) + t.Cleanup(f.srv.Close) + return f +} + +func writeJSON(w http.ResponseWriter, code int, body string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _, _ = w.Write([]byte(body)) +} + +func (f *fakeTelegram) sendCalls() []map[string]string { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]map[string]string, 0, len(f.calls)) + for _, c := range f.calls { + if strings.HasSuffix(c["method"], "/sendMessage") || strings.HasSuffix(c["method"], "/editMessageText") { + out = append(out, c) + } + } + return out +} + +func newTestAdapter(t *testing.T, f *fakeTelegram) *Adapter { + t.Helper() + bot, err := tgbotapi.NewBotAPIWithAPIEndpoint("TOKEN", f.srv.URL+"/bot%s/%s") + if err != nil { + t.Fatalf("bot: %v", err) + } + return &Adapter{bot: bot, pollingTimeout: time.Second, httpClient: f.srv.Client()} +} + +const approvedLayout = "01.09 — 14.09 · 14 дней\n" + + "Пришло 40 968 ฿\n" + + "**Куда уйдут**\n" + + "```\n" + + "Аренда 15.09 12 000\n" + + "Еда 8 000\n" + + "```\n" + + "**На день: 1 200 ฿**" + +// Утверждённый формат раскладки доезжает разметкой: parse_mode=HTML, +// моноблок — настоящий, заголовки — , тройных кавычек в payload нет. +func TestAdapterSend_EnvelopeLayoutGoesAsHTML(t *testing.T) { + f := newFakeTelegram(t) + a := newTestAdapter(t, f) + + if err := a.Send(context.Background(), 1, approvedLayout); err != nil { + t.Fatalf("send: %v", err) + } + calls := f.sendCalls() + if len(calls) != 1 { + t.Fatalf("ожидался один sendMessage, получено %d", len(calls)) + } + got := calls[0] + if got["parse_mode"] != "HTML" { + t.Errorf("parse_mode = %q, ожидался HTML", got["parse_mode"]) + } + text := got["text"] + if !strings.Contains(text, "Аренда 15.09 12 000\nЕда 8 000") { + t.Errorf("моноблок не доехал как pre:\n%s", text) + } + if !strings.Contains(text, "Куда уйдут") || !strings.Contains(text, "На день: 1 200 ฿") { + t.Errorf("заголовки не жирные:\n%s", text) + } + if strings.Contains(text, "```") || strings.Contains(text, "**") { + t.Errorf("разметка уехала литералом:\n%s", text) + } +} + +// Регресс: обычный ответ скилла уходит одним сообщением и без изменений — +// разметка не должна ничего добавлять к тексту без спецсимволов. +func TestAdapterSend_PlainSkillReplyIntact(t *testing.T) { + f := newFakeTelegram(t) + a := newTestAdapter(t, f) + + const reply = "✅ Записал: Еда — 250 ฿ (15.09)" + if err := a.Send(context.Background(), 1, reply); err != nil { + t.Fatalf("send: %v", err) + } + calls := f.sendCalls() + if len(calls) != 1 || calls[0]["text"] != reply { + t.Fatalf("обычный ответ изменился: %#v", calls) + } +} + +// Злые строки от оператора и LLM экранируются, а не роняют отправку. +func TestAdapterSend_EscapesEvilStrings(t *testing.T) { + f := newFakeTelegram(t) + a := newTestAdapter(t, f) + + if err := a.Send(context.Background(), 1, "Кафе <Мама & Папа> 15.09 — 1 200 ฿"); err != nil { + t.Fatalf("send: %v", err) + } + text := f.sendCalls()[0]["text"] + if !strings.Contains(text, "<Мама & Папа>") { + t.Fatalf("не экранировано: %q", text) + } +} + +// Длинное сообщение не роняет отправку: режется на части, каждая в лимите. +func TestAdapterSend_LongMessageSplit(t *testing.T) { + f := newFakeTelegram(t) + a := newTestAdapter(t, f) + + long := strings.Repeat("Категория очень длинная 1 234\n", 400) + if err := a.Send(context.Background(), 1, long); err != nil { + t.Fatalf("send: %v", err) + } + calls := f.sendCalls() + if len(calls) < 2 { + t.Fatalf("длинное сообщение ушло %d куском(ами)", len(calls)) + } + for i, c := range calls { + if n := len([]rune(c["text"])); n > 4096 { + t.Errorf("кусок %d длиной %d — Telegram вернёт 400", i, n) + } + } +} + +// Если Telegram всё-таки ответил 400 на разметку — сообщение уходит простым +// текстом. Пользователь обязан получить содержимое, а не тишину. +func TestAdapterSend_FallsBackToPlainOn400(t *testing.T) { + f := newFakeTelegram(t) + f.failOn = func(call map[string]string) bool { return call["parse_mode"] == "HTML" } + a := newTestAdapter(t, f) + + if err := a.Send(context.Background(), 1, approvedLayout); err != nil { + t.Fatalf("фоллбэк не сработал: %v", err) + } + calls := f.sendCalls() + last := calls[len(calls)-1] + if last["parse_mode"] != "" { + t.Errorf("фоллбэк ушёл с parse_mode=%q", last["parse_mode"]) + } + if !strings.Contains(last["text"], "Аренда") { + t.Errorf("фоллбэк потерял содержимое: %q", last["text"]) + } +} + +// Кнопки вешаются на ПОСЛЕДНИЙ кусок: иначе при разрезе клавиатура уезжает в +// середину переписки и остаётся выше текста, к которому относится. +func TestAdapterSendWithButtons_KeyboardOnLastChunk(t *testing.T) { + f := newFakeTelegram(t) + a := newTestAdapter(t, f) + + long := strings.Repeat("строка отчёта 1 234\n", 400) + rows := [][]core.Button{{{Text: "Да", Data: "yes"}}} + if err := a.SendWithButtons(context.Background(), 1, long, rows); err != nil { + t.Fatalf("send: %v", err) + } + calls := f.sendCalls() + if len(calls) < 2 { + t.Fatalf("ожидался разрез, получено %d", len(calls)) + } + for i, c := range calls { + _, has := c["reply_markup"] + if want := i == len(calls)-1; has != want { + t.Errorf("кусок %d: reply_markup=%v, ожидалось %v", i, has, want) + } + } + var kb tgbotapi.InlineKeyboardMarkup + if err := json.Unmarshal([]byte(calls[len(calls)-1]["reply_markup"]), &kb); err != nil { + t.Fatalf("клавиатура не разобралась: %v", err) + } + if len(kb.InlineKeyboard) != 1 || kb.InlineKeyboard[0][0].Text != "Да" { + t.Fatalf("клавиатура потерялась: %#v", kb) + } +} + +// Правка сообщения тоже идёт с разметкой — иначе отредактированная раскладка +// разваливается там, где исходная была цела. +func TestAdapterEditWithButtons_UsesHTML(t *testing.T) { + f := newFakeTelegram(t) + a := newTestAdapter(t, f) + + if err := a.EditWithButtons(context.Background(), 1, 7, approvedLayout, nil); err != nil { + t.Fatalf("edit: %v", err) + } + calls := f.sendCalls() + if len(calls) != 1 { + t.Fatalf("ожидался один editMessageText, получено %d", len(calls)) + } + if calls[0]["parse_mode"] != "HTML" || !strings.Contains(calls[0]["text"], "") { + t.Fatalf("правка ушла без разметки: %#v", calls[0]) + } +} + +// Пустой (но не nil) набор рядов — это «снять клавиатуру» после обработки +// callback-а. Не приложить reply_markup здесь значит оставить старые кнопки +// живыми на уже переписанном сообщении. +func TestAdapterEditWithButtons_EmptyRowsClearKeyboard(t *testing.T) { + f := newFakeTelegram(t) + a := newTestAdapter(t, f) + + if err := a.EditWithButtons(context.Background(), 1, 7, "готово", [][]core.Button{}); err != nil { + t.Fatalf("edit: %v", err) + } + calls := f.sendCalls() + if len(calls) != 1 { + t.Fatalf("ожидался один editMessageText, получено %d", len(calls)) + } + raw, ok := calls[0]["reply_markup"] + if !ok { + t.Fatal("reply_markup не отправлен — старые кнопки останутся на сообщении") + } + var kb tgbotapi.InlineKeyboardMarkup + if err := json.Unmarshal([]byte(raw), &kb); err != nil { + t.Fatalf("клавиатура не разобралась: %v", err) + } + if len(kb.InlineKeyboard) != 0 { + t.Fatalf("ожидалась пустая клавиатура, получено %#v", kb.InlineKeyboard) + } +} + +// Правка с кривой разметкой не должна теряться: 400 на HTML → повтор простым текстом. +func TestAdapterEditWithButtons_FallsBackToPlainOn400(t *testing.T) { + f := newFakeTelegram(t) + f.failOn = func(call map[string]string) bool { return call["parse_mode"] == "HTML" } + a := newTestAdapter(t, f) + + if err := a.EditWithButtons(context.Background(), 1, 7, approvedLayout, nil); err != nil { + t.Fatalf("edit: %v", err) + } + calls := f.sendCalls() + if len(calls) != 2 { + t.Fatalf("ожидались HTML-попытка и фоллбэк, получено %d", len(calls)) + } + if calls[1]["parse_mode"] != "" || strings.Contains(calls[1]["text"], "") { + t.Fatalf("фоллбэк ушёл с разметкой: %#v", calls[1]) + } +} diff --git a/internal/agent/router.go b/internal/agent/router.go index 51586e8..78279da 100644 --- a/internal/agent/router.go +++ b/internal/agent/router.go @@ -28,7 +28,16 @@ func buildToolsSystemPrompt(manifests []plugin.Manifest) string { sb.WriteString("\nROUTING RULES (приоритет выше описаний инструментов):\n") sb.WriteString("1. Будущая покупка / совет о покупке («хочу купить», «планирую купить», «думаю купить», «стоит ли купить», «можем ли позволить», «хватит ли денег на», «потянем ли», «что приоритетнее») — ВСЕГДА skill=advisor, action=advice. НИКОГДА не вызывай budget.add_expense для будущих/гипотетических покупок.\n") sb.WriteString("2. Запись СОВЕРШЁННОЙ траты (прошедшее время: «купил», «купила», «потратил», «заплатил», «оплатил») — skill=budget, action=add_expense.\n") - sb.WriteString("3. Если в сообщении нет суммы и нет глагола в прошедшем времени — это НЕ add_expense.\n") + sb.WriteString("3. Правка ЛИМИТА конверта (доли прихода) — skill=budget, action=set_share_limit, поля name=<категория>, amount=<сумма>: «на еду хватит 15000», «на транспорт закладывай 5000», «лимит на развлечения 3000», «ставь на еду 15000». Денег НЕ потратили — это план на будущее, поэтому НЕ add_expense.\n") + sb.WriteString(" Снятие лимита («убери лимит на еду», «сними лимит с транспорта», «считай лимит на еду сам») — skill=budget, action=clear_share_limit, поле name=<категория>.\n") + sb.WriteString(" Разграничение с правилом 1: ВОПРОС «хватит ли на <покупку>?» — это advisor.advice; УТВЕРЖДЕНИЕ «на <категорию> хватит <сумма>» (сумма названа, вопроса нет) — это budget.set_share_limit.\n") + sb.WriteString(" Разграничение с правилом 2: «купил еды на 3000» — трата уже случилась (прошедшее время) → budget.add_expense; «на еду хватит 15000» — трат нет, задан лимит → budget.set_share_limit.\n") + sb.WriteString(" set_share_limit требует НАЗВАННОЙ суммы. Вопрос без суммы («сколько откладывать на машину», «сколько закладывать на еду») — это совет, skill=advisor, action=advice.\n") + sb.WriteString("4. РАСКЛАДКА пришедшего дохода по конвертам («пришло 127000, разложи по конвертам», «разложи приход X по конвертам», «раскидай X по конвертам», «распредели X по конвертам») — skill=budget, action=start_envelope, поля amount, currency. Раскладка ПИШЕТ конверт; safe_to_spend только считает и ничего не сохраняет, поэтому для «разложи по конвертам» он НЕ подходит. Без слова про конверты («пришло X, сколько свободно?») — по-прежнему safe_to_spend.\n") + sb.WriteString("5. Вопрос про ОСТАТОК по категории или по конвертам («сколько осталось на еду», «сколько в конвертах», «сколько осталось в конверте на транспорт») — skill=safe_to_spend, сумму НЕ передавать. Это не budget.summary: спрашивают не сколько потрачено, а сколько ещё можно потратить.\n") + sb.WriteString("6. ВАЛЮТА КОНВЕРТОВ. По умолчанию конверты показываются В БАТАХ (THB) — поле display_currency НЕ заполняй. Если оператор попросил рубли («покажи конверты в рублях», «разложи и покажи в рублях», «сколько это в рублях») — передай display_currency=\"RUB\"; если явно попросил баты («в батах») — display_currency=\"THB\". Поле display_currency — только про ПОКАЗ; сумму из сообщения оно не меняет.\n") + sb.WriteString(" Валюта САМОЙ СУММЫ идёт в currency: «пришло 127000₽» → currency=\"RUB\"; «на еду хватит 5000 бат» → currency=\"THB\"; «на еду хватит 15000 рублей» → currency=\"RUB\". Не путай currency с display_currency: первая описывает названную сумму, вторая — валюту ответа.\n") + sb.WriteString("7. Если в сообщении нет суммы и нет глагола в прошедшем времени — это НЕ add_expense.\n") sb.WriteString("\nДоступные инструменты:\n") for _, m := range manifests { fmt.Fprintf(&sb, "- %s: %s\n", m.ID, m.Description) diff --git a/internal/agent/routing_rules_test.go b/internal/agent/routing_rules_test.go new file mode 100644 index 0000000..490372d --- /dev/null +++ b/internal/agent/routing_rules_test.go @@ -0,0 +1,52 @@ +package agent + +import ( + "strings" + "testing" + + "simpleAI/internal/plugin" +) + +// Разграничение «правка лимита конверта» vs «запись траты» (ADR-008) живёт +// именно в блоке ROUTING RULES, а не в описании budget-скилла: блок объявлен +// приоритетнее описаний инструментов, и проверка мутацией (ADR-008, «Где на +// самом деле живёт триггер») показала, что вырезание триггеров add_expense из +// Manifest'а маршрут не меняет — решает этот хардкод. +// +// Тест доказывает ровно одно: правило физически присутствует в промпте и стоит +// до списка инструментов. Что модель ему следует — доказывают golden-кейсы r046 +// («на еду хватит 15000» → budget.set_share_limit) и r047 («купил еды на 3000» +// → budget.add_expense), они гоняются на боевой модели. +func TestBuildToolsSystemPrompt_ShareLimitRuleSeparatedFromExpense(t *testing.T) { + prompt := buildToolsSystemPrompt([]plugin.Manifest{ + {ID: "budget", Description: "budget tracker"}, + {ID: "advisor", Description: "advisor"}, + {ID: "safe_to_spend", Description: "safe to spend"}, + }) + + rules := prompt[:strings.Index(prompt, "Доступные инструменты")] + if rules == "" { + t.Fatal("в промпте нет блока правил до списка инструментов") + } + + mustContain := map[string]string{ + "set_share_limit": "нет правила про правку лимита конверта", + "clear_share_limit": "нет правила про снятие лимита", + "на еду хватит": "нет фразы-триггера правки лимита", + "купил еды на 3000": "нет контраста с записью траты (разграничение ломалось в simpleAI-399 / simpleAI-q49)", + "хватит ли на": "нет разграничения с advisor.advice («хватит ли на <покупку>»)", + } + for marker, why := range mustContain { + if !strings.Contains(rules, marker) { + t.Errorf("%s: в ROUTING RULES нет %q", why, marker) + } + } + + // Правило про лимит обязано стоять ПОСЛЕ правила про add_expense: сначала + // модель читает общий случай траты, потом исключение из него. + expenseIdx := strings.Index(rules, "add_expense") + limitIdx := strings.Index(rules, "set_share_limit") + if expenseIdx == -1 || limitIdx == -1 || limitIdx < expenseIdx { + t.Errorf("правило про лимит должно идти после правила про add_expense (expense=%d, limit=%d)", expenseIdx, limitIdx) + } +} diff --git a/internal/bot/format/telegram_html.go b/internal/bot/format/telegram_html.go new file mode 100644 index 0000000..f2bb1cd --- /dev/null +++ b/internal/bot/format/telegram_html.go @@ -0,0 +1,234 @@ +package botformat + +import ( + "strings" + "unicode/utf8" +) + +// Разметка Telegram включена в режиме HTML, а не MarkdownV2 (ADR-008). +// Причина одна и она про доставку: в MarkdownV2 экранирования требуют +// восемнадцать символов, включая точку и минус, — они есть буквально в каждом +// сообщении с датой и суммой. Любой пропущенный символ в имени категории, в +// названии платежа или в тексте от LLM — это 400 от Telegram и НОЛЬ сообщений +// у пользователя. В HTML опасных символов три: & < >. Их экранирование +// централизовано здесь, и обе нужные конструкции —и — покрыты. +// +// Скиллы продолжают писать текст в привычной ограниченной разметке +// (моноблок в тройных кавычках и **жирный**) — это структура намерения, +// а не готовый Markdown: рендер ниже переводит её в HTML, экранируя ВСЁ +// остальное. Постфактум-экранирование готовой разметки не выбрано намеренно: +// оно требует отличать «звёздочку-разметку» от «звёздочки из текста LLM», +// а это ровно тот класс ошибки, который роняет отправку целиком. +const ( + // telegramTextLimit — потолок длины одного сообщения. Реальный лимит + // Telegram 4096; берём с запасом, потому что HTML-сущности (&) + // длиннее исходного символа, а лимит считается по разобранному тексту. + telegramTextLimit = 3800 + + preOverhead = len("") +) + +var htmlEscaper = strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", +) + +// EscapeHTML экранирует три символа, опасных для parse_mode=HTML. +func EscapeHTML(s string) string { return htmlEscaper.Replace(s) } + +// item — одна строка исходного текста с признаком «внутри моноблока». +type item struct { + text string + pre bool +} + +// isFence: строка-ограничитель моноблока. Ограничителем считается только +// целая строка — тройные кавычки посреди текста остаются текстом. +func isFence(line string) bool { + return strings.HasPrefix(strings.TrimSpace(line), "```") +} + +// parseItems раскладывает исходный текст на строки, помечая те, что лежат +// внутри ПАРНОГО моноблока. Непарный ограничитель остаётся обычным текстом: +// отдать Telegram незакрытый— это 400, то есть пользователь не получит +// ничего вообще. +func parseItems(src string) []item { + lines := strings.Split(src, "\n") + + fences := make([]int, 0, 4) + for i, ln := range lines { + if isFence(ln) { + fences = append(fences, i) + } + } + inPre := make([]bool, len(lines)) + skip := make([]bool, len(lines)) + for i := 0; i+1 < len(fences); i += 2 { + open, close := fences[i], fences[i+1] + skip[open], skip[close] = true, true + for j := open + 1; j < close; j++ { + inPre[j] = true + } + } + + items := make([]item, 0, len(lines)) + for i, ln := range lines { + if skip[i] { + continue + } + items = append(items, item{text: ln, pre: inPre[i]}) + } + return items +} + +// boldify превращает парные ** в . Работает по УЖЕ экранированной строке: +// экранирование звёздочек не порождает, так что подмены смысла нет. +// Непарная ** остаётся литералом — она частая в тексте от LLM и не должна +// ни ронять отправку, ни съедать хвост сообщения. +func boldify(escaped string) string { + var b strings.Builder + rest := escaped + for { + open := strings.Index(rest, "**") + if open < 0 { + break + } + after := rest[open+2:] + closeAt := strings.Index(after, "**") + if closeAt <= 0 { // нет пары или пустое **** — литерал + break + } + b.WriteString(rest[:open]) + b.WriteString("") + b.WriteString(after[:closeAt]) + b.WriteString("") + rest = after[closeAt+2:] + } + b.WriteString(rest) + return b.String() +} + +// renderItems собирает HTML: подряд идущие строки моноблока склеиваются в +// ОДИН, остальные экранируются и получают на парных **. +func renderItems(items []item) string { + var b strings.Builder + for i := 0; i < len(items); i++ { + if !items[i].pre { + b.WriteString(boldify(EscapeHTML(items[i].text))) + if i+1 < len(items) { + b.WriteString("\n") + } + continue + } + j := i + var block []string + for ; j < len(items) && items[j].pre; j++ { + block = append(block, EscapeHTML(items[j].text)) + } + b.WriteString("") + b.WriteString(strings.Join(block, "\n")) + b.WriteString("") + if j < len(items) { + b.WriteString("\n") + } + i = j - 1 + } + return b.String() +} + +// RenderHTML переводит ограниченную разметку скиллов (моноблок в тройных +// кавычках, **жирный**) в HTML для parse_mode=HTML, экранируя всё остальное. +func RenderHTML(src string) string { return renderItems(parseItems(src)) } + +// MessagesHTML — то, что реально уходит в Telegram: готовые HTML-куски в +// пределах лимита длины. Длинное сообщение режется, а не роняет отправку; +// разрез моноблока закрываети открывает новый в следующем куске. +func MessagesHTML(src string) []string { + if strings.TrimSpace(src) == "" { + return nil + } + items := splitOverlong(parseItems(src)) + + var out []string + var chunk []item + cost := 0 + flush := func() { + if len(chunk) == 0 { + return + } + out = append(out, renderItems(chunk)) + chunk, cost = nil, 0 + } + for _, it := range items { + c := itemCost(it) + if len(chunk) > 0 && cost+c > telegramTextLimit { + flush() + } + chunk = append(chunk, it) + cost += c + } + flush() + return out +} + +// MessagesPlain — тот же текст без всякой разметки, кусками в пределах лимита. +// Нужен для фоллбэка: если Telegram всё-таки ответил 400 на размеченный +// вариант, сообщение обязано доехать хотя бы простым текстом. Молчание бота +// хуже, чем звёздочки в чате. +func MessagesPlain(src string) []string { + if strings.TrimSpace(src) == "" { + return nil + } + var out []string + var b strings.Builder + n := 0 + for _, r := range src { + if n >= telegramTextLimit { + out = append(out, b.String()) + b.Reset() + n = 0 + } + b.WriteRune(r) + n++ + } + if b.Len() > 0 { + out = append(out, b.String()) + } + return out +} + +// itemCost — верхняя оценка вклада строки в длину куска: экранированный текст, +// перевод строки и обёртка(считается на каждую строку блока, потому что +// границы блока в момент подсчёта ещё не известны). +func itemCost(it item) int { + c := utf8.RuneCountInString(EscapeHTML(it.text)) + 1 + if it.pre { + c += preOverhead + } + return c +} + +// splitOverlong режет строки, которые сами по себе длиннее лимита. Такие +// приходят от LLM (простыня без переносов) и без этого шага гарантировали бы +// кусок сверх лимита, то есть 400. +func splitOverlong(items []item) []item { + out := make([]item, 0, len(items)) + for _, it := range items { + if itemCost(it) <= telegramTextLimit { + out = append(out, it) + continue + } + runes := []rune(it.text) + // Шаг с запасом на экранирование: & занимает 5 знаков вместо 1. + step := telegramTextLimit/5 - preOverhead + for start := 0; start < len(runes); start += step { + end := start + step + if end > len(runes) { + end = len(runes) + } + out = append(out, item{text: string(runes[start:end]), pre: it.pre}) + } + } + return out +} diff --git a/internal/bot/format/telegram_html_test.go b/internal/bot/format/telegram_html_test.go new file mode 100644 index 0000000..560bb04 --- /dev/null +++ b/internal/bot/format/telegram_html_test.go @@ -0,0 +1,141 @@ +package botformat + +import ( + "strings" + "testing" +) + +// Злые строки: имена категорий и платежей, которые реально приходят от +// оператора и от LLM. В HTML опасны ровно три символа — их и проверяем, +// плюс весь набор спецсимволов MarkdownV2 (он обязан доехать как есть). +func TestRenderHTML_EscapesEvilStrings(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"амперсанд", "Кафе Мама & Папа", "Кафе Мама & Папа"}, + {"угловые скобки", "аренда <дом> 15.09", "аренда <дом> 15.09"}, + {"похоже на тег", "не тег", "<b>не тег</b>"}, + {"спецсимволы MarkdownV2", "_ [ ] ( ) ~ > # + - = | { } . !", "_ [ ] ( ) ~ > # + - = | { } . !"}, + {"одиночная звёздочка", "3*4 = 12", "3*4 = 12"}, + {"непарный **", "цена ** дорого", "цена ** дорого"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := RenderHTML(tc.in) + if got != tc.want { + t.Fatalf("RenderHTML(%q)\n got: %q\nwant: %q", tc.in, got, tc.want) + } + }) + } +} + +// Моноблок из утверждённого формата раскладки обязан доехать как настоящий +//: только в нём Telegram держит моноширинный шрифт и колонку чисел. +func TestRenderHTML_MonoblockBecomesPre(t *testing.T) { + src := "**Куда уйдут**\n```\nЕда 8 000\nАренда 15.09 12 000\n```\nхвост" + got := RenderHTML(src) + + if !strings.Contains(got, "Куда уйдут") { + t.Errorf("заголовок блока не стал жирным: %q", got) + } + if strings.Contains(got, "```") { + t.Errorf("тройные кавычки уехали литералом: %q", got) + } + if !strings.Contains(got, "Еда 8 000\nАренда 15.09 12 000") { + t.Errorf("моноблок не стал pre: %q", got) + } + if !strings.HasSuffix(got, "\nхвост") { + t.Errorf("текст после моноблока потерян: %q", got) + } +} + +// Внутри pre разметка не интерпретируется: имя платежа со звёздочками или +// угловой скобкой не должно порождать тегов и не должно ронять отправку. +func TestRenderHTML_PreContentIsVerbatim(t *testing.T) { + src := "```\n**Кафе** & <дом>\n```" + got := RenderHTML(src) + want := "**Кафе** & <дом>" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +// Незакрытый моноблок — не повод отдать Telegram сломанный HTML: 400 на такой +// разметке означает, что пользователь не получит НИЧЕГО. +func TestRenderHTML_UnclosedFenceStaysLiteral(t *testing.T) { + got := RenderHTML("текст\n```\nЕда 8 000") + if strings.Contains(got, "") { + t.Fatalf("незакрытый блок открыл тег: %q", got) + } + if !strings.Contains(got, "```") { + t.Fatalf("незакрытый блок должен остаться литералом: %q", got) + } +} + +// Регресс: обычный ответ скилла (трата записана, дайджест) не должен +// обрастать тегами и не должен ничего терять. +func TestRenderHTML_PlainSkillReplyUnchanged(t *testing.T) { + src := "✅ Записал: Еда — 250 ฿ (15.09)\n👉 На день: 1 200 ฿ (12 дней до конца периода)" + if got := RenderHTML(src); got != src { + t.Fatalf("обычный ответ изменился:\n got: %q\nwant: %q", got, src) + } +} + +// Длинное сообщение не роняет отправку: режется на части в пределах лимита +// Telegram, и моноблок при разрезе закрывается и переоткрывается. +func TestMessagesHTML_SplitsLongMessage(t *testing.T) { + var b strings.Builder + b.WriteString("**Куда уйдут**\n```\n") + for i := 0; i < 900; i++ { + b.WriteString("Категория очень длинная 1 234\n") + } + b.WriteString("```\n") + + parts := MessagesHTML(b.String()) + if len(parts) < 2 { + t.Fatalf("длинное сообщение не разрезано: частей %d", len(parts)) + } + for i, p := range parts { + if n := len([]rune(p)); n > telegramTextLimit { + t.Errorf("часть %d длиной %d рун — Telegram вернёт 400", i, n) + } + if strings.Count(p, "") != strings.Count(p, "") { + t.Errorf("часть %d с несбалансированным pre: %.80q", i, p) + } + if strings.Contains(p, "```") { + t.Errorf("часть %d содержит литеральные кавычки", i) + } + } +} + +// Короткое сообщение остаётся одним куском — резать нечего. +func TestMessagesHTML_ShortStaysSingle(t *testing.T) { + parts := MessagesHTML("привет") + if len(parts) != 1 || parts[0] != "привет" { + t.Fatalf("got %#v", parts) + } +} + +// Пустой текст не порождает пустой отправки (Telegram вернёт 400 на пустом +// text — и это был бы ложный «сбой доставки»). +func TestMessagesHTML_EmptyStaysEmpty(t *testing.T) { + if parts := MessagesHTML(" "); len(parts) != 0 { + t.Fatalf("пустой текст дал части: %#v", parts) + } +} + +// Одна строка длиннее лимита (LLM умеет выдать простыню без переносов) +// режется принудительно, а не уезжает в 400. +func TestMessagesHTML_OverlongSingleLine(t *testing.T) { + parts := MessagesHTML(strings.Repeat("я", telegramTextLimit+500)) + if len(parts) < 2 { + t.Fatalf("сверхдлинная строка не разрезана: %d", len(parts)) + } + for i, p := range parts { + if n := len([]rune(p)); n > telegramTextLimit { + t.Errorf("часть %d длиной %d рун", i, n) + } + } +} diff --git a/internal/budget/envelope_close_test.go b/internal/budget/envelope_close_test.go new file mode 100644 index 0000000..a31fa52 --- /dev/null +++ b/internal/budget/envelope_close_test.go @@ -0,0 +1,214 @@ +package budget + +import ( + "context" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +func closeTestPool(t *testing.T) (*pgxpool.Pool, context.Context) { + t.Helper() + url := os.Getenv("BOTCLIENT_DATABASE_URL_RW") + if url == "" { + t.Skip("BOTCLIENT_DATABASE_URL_RW не задан — write-доступ к реплике недоступен") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(pool.Close) + return pool, ctx +} + +func closeTestShares() []EnvelopeShare { + return []EnvelopeShare{ + {Name: "Еда", Kind: ShareKindSpend, Allocated: 10000, Source: ShareSourceAuto, Position: 0, + Categories: []EnvelopeShareCategory{{CategoryName: "еда"}}}, + {Name: "Накопления", Kind: ShareKindSave, Allocated: 5000, Source: ShareSourceAuto, Position: 1}, + } +} + +// TestEnvelopeClose_SwitchDayCountedOnce — гейт ADR-008 §10. +// +// Трата дня переключения обязана попасть РОВНО в один конверт. Все даты в схеме +// DATE, а фильтры границ включительные, поэтому проверка идёт не по полю +// period_end как таковому, а по факту: сумма за период закрытого конверта плюс +// сумма за период нового должны дать одну трату, а не две. +// +// Мутация: вернуть `period_end := now` (убрать «− 1») в insertEnvelopeTx — трата +// попадёт в оба периода, sum станет 2000 вместо 1000, тест краснеет. +func TestEnvelopeClose_SwitchDayCountedOnce(t *testing.T) { + pool, ctx := closeTestPool(t) + s := NewStore(pool) + + const chatID = int64(-70051) + // Окно в прошлом — чтобы не пересечься с данными дампа. + switchDay := time.Date(2002, 5, 20, 0, 0, 0, 0, time.UTC) + oldFrom := switchDay.AddDate(0, 0, -10) + + var catID uuid.UUID + if err := pool.QueryRow(ctx, + `SELECT id FROM budget_category WHERE type = 'expense' AND lower(name) = 'еда' LIMIT 1`).Scan(&catID); err != nil { + if err := pool.QueryRow(ctx, + `SELECT id FROM budget_category WHERE type = 'expense' ORDER BY sort_order LIMIT 1`).Scan(&catID); err != nil { + t.Fatalf("нет ни одной расходной категории: %v", err) + } + } + + txID := uuid.New() + cleanup := func() { + if _, err := pool.Exec(ctx, `DELETE FROM budget_envelope WHERE chat_id = $1`, chatID); err != nil { + t.Logf("cleanup envelope: %v", err) + } + if _, err := pool.Exec(ctx, `DELETE FROM budget_transaction WHERE id = $1`, txID); err != nil { + t.Logf("cleanup tx: %v", err) + } + } + cleanup() + defer cleanup() + + oldID, err := s.CreateEnvelopeWithShares(ctx, chatID, 100000, "RUB", + oldFrom, switchDay.AddDate(0, 0, 4), closeTestShares(), oldFrom) + if err != nil { + t.Fatalf("create old envelope: %v", err) + } + + // Трата ровно в день переключения. + if _, err := pool.Exec(ctx, ` + INSERT INTO budget_transaction (id, type, amount, currency, category_id, description, transaction_date) + VALUES ($1, 'expense', 1000, 'THB', $2, 'тест: трата дня переключения', $3) + `, txID, catID, switchDay); err != nil { + t.Fatalf("insert tx: %v", err) + } + + newFrom := switchDay + if _, err := s.CreateEnvelopeWithShares(ctx, chatID, 120000, "RUB", + newFrom, switchDay.AddDate(0, 0, 14), closeTestShares(), switchDay); err != nil { + t.Fatalf("create new envelope: %v", err) + } + + var oldEnd time.Time + var oldActive bool + if err := pool.QueryRow(ctx, + `SELECT period_end, active FROM budget_envelope WHERE id = $1`, oldID).Scan(&oldEnd, &oldActive); err != nil { + t.Fatalf("read old envelope: %v", err) + } + if oldActive { + t.Errorf("прошлый конверт остался активным") + } + want := switchDay.AddDate(0, 0, -1) + if !oldEnd.Equal(want) { + t.Errorf("period_end закрытого конверта = %s, ожидалось %s (now − 1 день, ADR-008 §10)", + oldEnd.Format("2006-01-02"), want.Format("2006-01-02")) + } + + sumFor := func(from, to time.Time) float64 { + if to.Before(from) { + return 0 + } + rows, err := s.SpentByCategoryExcludingRecurring(ctx, from, to) + if err != nil { + t.Fatalf("spent %s..%s: %v", from.Format("2006-01-02"), to.Format("2006-01-02"), err) + } + var total float64 + for _, r := range rows { + if r.CategoryID != nil && *r.CategoryID == catID { + total += r.Amount + } + } + return total + } + + inOld := sumFor(oldFrom, oldEnd) + inNew := sumFor(newFrom, switchDay.AddDate(0, 0, 14)) + if inOld+inNew != 1000 { + t.Fatalf("трата дня переключения учтена %.0f раз(а): старый конверт %.0f + новый %.0f, ожидалось ровно 1000 суммарно", + (inOld+inNew)/1000, inOld, inNew) + } + if inNew != 1000 { + t.Errorf("трата дня переключения должна достаться НОВОМУ конверту, got %.0f", inNew) + } +} + +// Конверт, заведённый и закрытый в один день: period_end := now − 1 ушёл бы +// раньше period_start и дал период отрицательной длины, который +// GetPeriodSnapshot не принимает. Обрезка до period_start (ADR-008 §10). +func TestEnvelopeClose_SameDayClampedToStart(t *testing.T) { + pool, ctx := closeTestPool(t) + s := NewStore(pool) + + const chatID = int64(-70052) + day := time.Date(2002, 6, 10, 0, 0, 0, 0, time.UTC) + cleanup := func() { + if _, err := pool.Exec(ctx, `DELETE FROM budget_envelope WHERE chat_id = $1`, chatID); err != nil { + t.Logf("cleanup envelope: %v", err) + } + } + cleanup() + defer cleanup() + + oldID, err := s.CreateEnvelopeWithShares(ctx, chatID, 100000, "RUB", + day, day.AddDate(0, 0, 14), closeTestShares(), day) + if err != nil { + t.Fatalf("create old envelope: %v", err) + } + if _, err := s.CreateEnvelopeWithShares(ctx, chatID, 120000, "RUB", + day, day.AddDate(0, 0, 14), closeTestShares(), day); err != nil { + t.Fatalf("create new envelope: %v", err) + } + + var start, end time.Time + if err := pool.QueryRow(ctx, + `SELECT period_start, period_end FROM budget_envelope WHERE id = $1`, oldID).Scan(&start, &end); err != nil { + t.Fatalf("read old envelope: %v", err) + } + if end.Before(start) { + t.Fatalf("период отрицательной длины: %s..%s", start.Format("2006-01-02"), end.Format("2006-01-02")) + } + if !end.Equal(start) { + t.Errorf("period_end = %s, ожидалась обрезка до period_start %s", + end.Format("2006-01-02"), start.Format("2006-01-02")) + } +} + +// Уже истёкший конверт закрытием НЕ удлиняется: его period_end в прошлом, и +// присвоение now − 1 задним числом втянуло бы в него чужие траты. +func TestEnvelopeClose_ExpiredNotExtended(t *testing.T) { + pool, ctx := closeTestPool(t) + s := NewStore(pool) + + const chatID = int64(-70053) + oldFrom := time.Date(2002, 7, 1, 0, 0, 0, 0, time.UTC) + oldTo := oldFrom.AddDate(0, 0, 14) + now := oldTo.AddDate(0, 0, 30) // конверт давно истёк + cleanup := func() { + if _, err := pool.Exec(ctx, `DELETE FROM budget_envelope WHERE chat_id = $1`, chatID); err != nil { + t.Logf("cleanup envelope: %v", err) + } + } + cleanup() + defer cleanup() + + oldID, err := s.CreateEnvelopeWithShares(ctx, chatID, 100000, "RUB", oldFrom, oldTo, closeTestShares(), oldFrom) + if err != nil { + t.Fatalf("create old envelope: %v", err) + } + if _, err := s.CreateEnvelopeWithShares(ctx, chatID, 120000, "RUB", + now, now.AddDate(0, 0, 14), closeTestShares(), now); err != nil { + t.Fatalf("create new envelope: %v", err) + } + + var end time.Time + if err := pool.QueryRow(ctx, `SELECT period_end FROM budget_envelope WHERE id = $1`, oldID).Scan(&end); err != nil { + t.Fatalf("read old envelope: %v", err) + } + if !end.Equal(oldTo) { + t.Errorf("истёкший конверт удлинён: period_end = %s, ожидалось %s", + end.Format("2006-01-02"), oldTo.Format("2006-01-02")) + } +} diff --git a/internal/budget/envelope_share_store.go b/internal/budget/envelope_share_store.go new file mode 100644 index 0000000..d9f178d --- /dev/null +++ b/internal/budget/envelope_share_store.go @@ -0,0 +1,73 @@ +package budget + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// Перезапись раскладки уже заведённого конверта (ADR-008 §2): оператор поправил +// лимит доли словами — базовый прогноз пересчитывается, override ложится сверху, +// доли переписываются. Отдельный файл от store.go: запись раскладки «поверх» +// живёт только здесь и не путается со вставкой новой раскладки (CreateShares / +// CreateEnvelopeWithShares). + +// ReplaceShares перезаписывает раскладку конверта ОДНОЙ транзакцией: старые доли +// удаляются, новые встают на их место. +// +// Одна транзакция — не оптимизация. Между DELETE и INSERT конверт остаётся без +// единой доли: budget.ResolveShare в этот момент возвращает nil (трате некуда +// падать), а «сколько осталось в конвертах» отвечать нечем. Двумя транзакциями +// это состояние становится наблюдаемым при любой ошибке между ними. +// +// Принадлежность конверта чату проверяется внутри той же транзакции: у долей +// своего chat_id нет (ADR-004 изоляция держится через budget_envelope), и без +// проверки чужой envelope_id переписал бы чужую раскладку. +func (s *Store) ReplaceShares(ctx context.Context, chatID int64, envelopeID uuid.UUID, shares []EnvelopeShare) error { + if envelopeID == uuid.Nil { + return fmt.Errorf("ReplaceShares: envelopeID пуст") + } + if len(shares) == 0 { + // Пустая раскладка стёрла бы доли и не поставила ни одной новой. Это не + // «нечего делать» (как в CreateShares, где конверт ещё без долей), а + // порча уже рабочего состояния — поэтому ошибка, а не тихий выход. + return fmt.Errorf("ReplaceShares: пустая раскладка") + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("ReplaceShares begin: %w", err) + } + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + slog.Warn("ReplaceShares rollback", "err", rbErr) + } + }() + + var owned bool + err = tx.QueryRow(ctx, ` + SELECT true FROM budget_envelope WHERE id = $1 AND chat_id = $2 + `, envelopeID, chatID).Scan(&owned) + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("ReplaceShares: конверт %s не найден в чате %d", envelopeID, chatID) + } + if err != nil { + return fmt.Errorf("ReplaceShares owner check: %w", err) + } + + // Категории долей уходят каскадом (FK ON DELETE CASCADE в 00017). + if _, err := tx.Exec(ctx, `DELETE FROM budget_envelope_share WHERE envelope_id = $1`, envelopeID); err != nil { + return fmt.Errorf("ReplaceShares delete: %w", err) + } + if err := insertSharesTx(ctx, tx, envelopeID, shares); err != nil { + return fmt.Errorf("ReplaceShares insert: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("ReplaceShares commit: %w", err) + } + return nil +} diff --git a/internal/budget/envelope_share_test.go b/internal/budget/envelope_share_test.go new file mode 100644 index 0000000..61b6c72 --- /dev/null +++ b/internal/budget/envelope_share_test.go @@ -0,0 +1,343 @@ +package budget + +import ( + "context" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// --- Матчинг категории в долю (чистая логика, БД не нужна) --- + +func ptrUUID(u uuid.UUID) *uuid.UUID { return &u } + +// shareFixture — раскладка из трёх долей: «еда» с категорией по id, +// «жильё» с категорией только по имени, и fallback «прочее» без категорий. +func shareFixture(foodID uuid.UUID) []EnvelopeShare { + return []EnvelopeShare{ + { + Name: "Еда", Kind: ShareKindSpend, Allocated: 12000, Source: ShareSourceAuto, Position: 0, + Categories: []EnvelopeShareCategory{{CategoryID: ptrUUID(foodID), CategoryName: "еда"}}, + }, + { + Name: "Жильё", Kind: ShareKindSpend, Allocated: 20000, Source: ShareSourceOverride, Position: 1, + Categories: []EnvelopeShareCategory{{CategoryName: "жильё"}}, + }, + { + Name: "Прочее", Kind: ShareKindSpend, Allocated: 3000, Source: ShareSourceAuto, Position: 2, + }, + } +} + +// Транзакция с category_id матчится в долю по id, даже если имя пришло другое +// (в БД имя могло быть переименовано после раскладки). +func TestResolveShare_ByCategoryID(t *testing.T) { + foodID := uuid.New() + shares := shareFixture(foodID) + + got := ResolveShare(shares, &foodID, "Продукты и кафе") + if got == nil || got.Name != "Еда" { + t.Fatalf("ожидали долю «Еда» по category_id, got %v", got) + } +} + +// Без category_id матчинг идёт по имени и обязан быть регистронезависимым: +// уникальный индекс budget_category(name,type) регистрозависим, поэтому в +// транзакциях встречаются и «Жильё», и «жильё», и «ЖИЛЬЁ». +func TestResolveShare_ByNameCaseInsensitive(t *testing.T) { + shares := shareFixture(uuid.New()) + + for _, name := range []string{"жильё", "Жильё", "ЖИЛЬЁ", " Жильё "} { + got := ResolveShare(shares, nil, name) + if got == nil || got.Name != "Жильё" { + t.Errorf("имя %q: ожидали долю «Жильё», got %v", name, got) + } + } +} + +// Нормализация нужна с ОБЕИХ сторон: раскладка, собранная в памяти (до +// CreateShares, который сам приводит имена к нижнему регистру), держит имена +// категорий как есть — «Развлечения». Матч обязан состояться и в этом случае. +func TestResolveShare_StoredNameNotNormalized(t *testing.T) { + shares := []EnvelopeShare{ + {Name: "Развлечения", Kind: ShareKindSpend, Position: 0, + Categories: []EnvelopeShareCategory{{CategoryName: "Развлечения"}}}, + {Name: "Прочее", Kind: ShareKindSpend, Position: 1}, + } + + for _, name := range []string{"развлечения", "Развлечения", "РАЗВЛЕЧЕНИЯ"} { + got := ResolveShare(shares, nil, name) + if got == nil || got.Name != "Развлечения" { + t.Errorf("имя %q: ожидали долю «Развлечения», got %v", name, got) + } + } +} + +// category_id IS NULL и имени нет — трата обязана уйти в fallback «прочее», +// а не потеряться и не попасть в первую попавшуюся долю. +func TestResolveShare_NilCategoryGoesToFallback(t *testing.T) { + shares := shareFixture(uuid.New()) + + got := ResolveShare(shares, nil, "") + if got == nil || got.Name != "Прочее" { + t.Fatalf("трата без категории должна попасть в «Прочее», got %v", got) + } + + // uuid.Nil трактуется как отсутствие категории. + nilID := uuid.Nil + if got := ResolveShare(shares, &nilID, ""); got == nil || got.Name != "Прочее" { + t.Fatalf("uuid.Nil должен вести в «Прочее», got %v", got) + } +} + +// Неизвестная категория (есть и id, и имя, но ни одна доля их не содержит) — +// тоже в fallback. +func TestResolveShare_UnknownCategoryGoesToFallback(t *testing.T) { + shares := shareFixture(uuid.New()) + other := uuid.New() + + got := ResolveShare(shares, &other, "Развлечения") + if got == nil || got.Name != "Прочее" { + t.Fatalf("неизвестная категория должна попасть в «Прочее», got %v", got) + } +} + +// Доля заведена по имени без id (категории не было в budget_category на момент +// раскладки), а транзакция пришла уже с id — матч обязан состояться по имени. +func TestResolveShare_IDMissFallsBackToName(t *testing.T) { + shares := shareFixture(uuid.New()) + housingID := uuid.New() + + got := ResolveShare(shares, &housingID, "Жильё") + if got == nil || got.Name != "Жильё" { + t.Fatalf("ожидали матч по имени при промахе по id, got %v", got) + } +} + +// Раскладка без fallback-доли: возвращаем nil, а не случайную долю — вызывающий +// сам решает, что делать с нераспределённой тратой. +func TestResolveShare_NoFallbackShare(t *testing.T) { + shares := shareFixture(uuid.New())[:2] // без «Прочее» + + if got := ResolveShare(shares, nil, "Развлечения"); got != nil { + t.Fatalf("без fallback-доли ожидали nil, got %v", got) + } +} + +// --- Контракт схемы --- + +const shareMigrationPath = "../db/migrations/00017_budget_envelope_shares.sql" + +// squashSQL убирает переносы и лишние пробелы, чтобы искать конструкции +// независимо от форматирования. +func squashSQL(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("читаю миграцию: %v", err) + } + return strings.ToLower(regexp.MustCompile(`\s+`).ReplaceAllString(string(raw), " ")) +} + +// Имя доли — ключ переноса накоплений между приходами: остаток «Отпуска» из +// прошлого периода ищется в новом ПО ИМЕНИ. Две доли с одним именем в одном +// конверте делают перенос неоднозначным. Уникальность должна быть структурной +// (constraint в схеме), а не проверкой в коде — иначе гонка двух раскладок +// пролезет мимо. Тест на неоднозначность ключа переноса. +// +// Прогон в CI без БД защищает только этот инвариант схемы; поведенческую +// проверку (INSERT дубликата отбивается) делает TestEnvelopeShares_Integration. +func TestShareMigration_TransferKeyIsUnambiguous(t *testing.T) { + sql := squashSQL(t, shareMigrationPath) + + unique := regexp.MustCompile(`unique\s*\(\s*envelope_id\s*,\s*name\s*\)`) + if !unique.MatchString(sql) { + t.Fatal("в 00017 нет UNIQUE(envelope_id, name): ключ переноса накоплений между приходами становится неоднозначным — две доли с одним именем в одном конверте") + } +} + +// Доли и их категории не должны переживать свой конверт: осиротевшая раскладка +// молча исказит следующий период. +func TestShareMigration_CascadeFromEnvelope(t *testing.T) { + sql := squashSQL(t, shareMigrationPath) + + for _, want := range []string{ + "references budget_envelope(id) on delete cascade", + "references budget_envelope_share(id) on delete cascade", + } { + if !strings.Contains(sql, want) { + t.Errorf("в 00017 нет %q — раскладка переживёт удаление конверта", want) + } + } +} + +// Миграция обязана иметь down-секцию: без неё goose down на чистой базе не +// откатывается. +func TestShareMigration_HasDown(t *testing.T) { + raw, err := os.ReadFile(shareMigrationPath) + if err != nil { + t.Fatalf("читаю миграцию: %v", err) + } + text := string(raw) + if !strings.Contains(text, "-- +goose Down") { + t.Fatal("в 00017 нет секции -- +goose Down") + } + for _, table := range []string{"budget_envelope_share", "budget_envelope_share_category", "budget_envelope_limit_override"} { + if !strings.Contains(text, "DROP TABLE IF EXISTS "+table) { + t.Errorf("down-секция не удаляет %s", table) + } + } +} + +// --- Интеграция с БД --- + +// TestEnvelopeShares_Integration проверяет каскадное удаление долей вместе с +// конвертом, изоляцию по chat_id и отбой дубликата имени доли. Требует WRITE — +// BOTCLIENT_DATABASE_URL_RW; иначе пропуск. +func TestEnvelopeShares_Integration(t *testing.T) { + url := os.Getenv("BOTCLIENT_DATABASE_URL_RW") + if url == "" { + t.Skip("BOTCLIENT_DATABASE_URL_RW не задан — write-доступ к реплике недоступен") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + s := NewStore(pool) + + const chatA, chatB = int64(-70011), int64(-70012) + cleanup := func() { + if _, err := pool.Exec(ctx, "DELETE FROM budget_envelope WHERE chat_id IN ($1,$2)", chatA, chatB); err != nil { + t.Logf("cleanup budget_envelope: %v", err) + } + if _, err := pool.Exec(ctx, "DELETE FROM budget_envelope_limit_override WHERE chat_id IN ($1,$2)", chatA, chatB); err != nil { + t.Logf("cleanup override: %v", err) + } + } + cleanup() + defer cleanup() + + from := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + to := from.AddDate(0, 0, 14) + envA, err := s.CreateEnvelope(ctx, chatA, 127000, "RUB", from, to) + if err != nil { + t.Fatalf("create envelope A: %v", err) + } + + // Категория из справочника — для матчинга по id. + var foodID uuid.UUID + if err := pool.QueryRow(ctx, `SELECT id FROM budget_category WHERE type = 'expense' ORDER BY sort_order LIMIT 1`).Scan(&foodID); err != nil { + t.Fatalf("нет ни одной расходной категории: %v", err) + } + + shares := []EnvelopeShare{ + {Name: "Еда", Kind: ShareKindSpend, Allocated: 12000, Source: ShareSourceAuto, Position: 0, + Categories: []EnvelopeShareCategory{{CategoryID: &foodID, CategoryName: "ЕДА"}}}, + {Name: "Отпуск", Kind: ShareKindSave, Allocated: 5000, CarriedIn: 1500, Source: ShareSourceOverride, Position: 1}, + {Name: "Прочее", Kind: ShareKindSpend, Allocated: 3000, Source: ShareSourceAuto, Position: 2}, + } + if err := s.CreateShares(ctx, envA, shares); err != nil { + t.Fatalf("CreateShares: %v", err) + } + + got, err := s.ListShares(ctx, chatA, envA) + if err != nil { + t.Fatalf("ListShares: %v", err) + } + if len(got) != 3 { + t.Fatalf("ожидали 3 доли, got %d", len(got)) + } + if got[0].Name != "Еда" || len(got[0].Categories) != 1 { + t.Fatalf("первая доля должна быть «Еда» с одной категорией, got %+v", got[0]) + } + // Имя категории обязано лечь в нижнем регистре — это ключ матчинга. + if got[0].Categories[0].CategoryName != "еда" { + t.Errorf("category_name должно храниться в нижнем регистре, got %q", got[0].Categories[0].CategoryName) + } + if got[1].CarriedIn != 1500 { + t.Errorf("carried_in потерян: got %v", got[1].CarriedIn) + } + // Матчинг по id на реальных данных. + if sh := ResolveShare(got, &foodID, "что угодно"); sh == nil || sh.Name != "Еда" { + t.Errorf("матч по category_id не сработал: %v", sh) + } + // Матчинг по имени в другом регистре. + if sh := ResolveShare(got, nil, "Еда"); sh == nil || sh.Name != "Еда" { + t.Errorf("матч по имени не сработал: %v", sh) + } + // category_id IS NULL и неизвестное имя — в fallback. + if sh := ResolveShare(got, nil, "Развлечения"); sh == nil || sh.Name != "Прочее" { + t.Errorf("fallback не сработал: %v", sh) + } + + // Дубликат имени доли в одном конверте отбивается схемой: имя — ключ + // переноса накоплений, второй «Отпуск» сделал бы перенос неоднозначным. + dup := []EnvelopeShare{{Name: "Отпуск", Kind: ShareKindSave, Allocated: 999, Source: ShareSourceAuto, Position: 9}} + if err := s.CreateShares(ctx, envA, dup); err == nil { + t.Error("вторая доля с именем «Отпуск» в том же конверте должна быть отбита UNIQUE(envelope_id, name)") + } + + // chat-scope: chatB не видит раскладку chatA даже зная envelope_id. + if other, err := s.ListShares(ctx, chatB, envA); err != nil { + t.Fatalf("ListShares chatB: %v", err) + } else if len(other) != 0 { + t.Errorf("chat-scope нарушен: chatB прочитал %d долей чужого конверта", len(other)) + } + + // Каскад: удаление конверта уносит доли и их категории. + var catCount int + if _, err := pool.Exec(ctx, `DELETE FROM budget_envelope WHERE id = $1`, envA); err != nil { + t.Fatalf("delete envelope: %v", err) + } + var shareCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM budget_envelope_share WHERE envelope_id = $1`, envA).Scan(&shareCount); err != nil { + t.Fatalf("count shares: %v", err) + } + if shareCount != 0 { + t.Errorf("доли пережили удаление конверта: %d", shareCount) + } + if err := pool.QueryRow(ctx, ` + SELECT count(*) FROM budget_envelope_share_category sc + LEFT JOIN budget_envelope_share sh ON sh.id = sc.share_id + WHERE sh.id IS NULL + `).Scan(&catCount); err != nil { + t.Fatalf("count orphan categories: %v", err) + } + if catCount != 0 { + t.Errorf("осиротевшие категории долей: %d", catCount) + } + + // Ручные лимиты: upsert, изоляция по chat_id, удаление. + if err := s.SetOverride(ctx, chatA, "Отпуск", 8000, "THB"); err != nil { + t.Fatalf("SetOverride: %v", err) + } + if err := s.SetOverride(ctx, chatA, "отпуск ", 9000, "THB"); err != nil { + t.Fatalf("SetOverride upsert: %v", err) + } + ovs, err := s.ListOverrides(ctx, chatA) + if err != nil { + t.Fatalf("ListOverrides: %v", err) + } + if len(ovs) != 1 || ovs[0].Amount != 9000 { + t.Fatalf("ожидали один лимит на 9000 (upsert по нормализованному имени), got %+v", ovs) + } + if ovs[0].ShareName != "отпуск" { + t.Errorf("share_name должно нормализоваться, got %q", ovs[0].ShareName) + } + if other, err := s.ListOverrides(ctx, chatB); err != nil || len(other) != 0 { + t.Errorf("chat-scope лимитов нарушен: %d (err=%v)", len(other), err) + } + if err := s.DeleteOverride(ctx, chatA, "ОТПУСК"); err != nil { + t.Fatalf("DeleteOverride: %v", err) + } + if ovs, err := s.ListOverrides(ctx, chatA); err != nil || len(ovs) != 0 { + t.Errorf("лимит не удалён: %d (err=%v)", len(ovs), err) + } +} diff --git a/internal/budget/envelope_test.go b/internal/budget/envelope_test.go index e7537fc..765dd0f 100644 --- a/internal/budget/envelope_test.go +++ b/internal/budget/envelope_test.go @@ -57,3 +57,47 @@ func TestEnvelope_Integration(t *testing.T) { t.Errorf("chat-scope нарушен: у chatB найден конверт (ok=%v)", ok) } } + +// Правило «один активный конверт на чат» держится СХЕМОЙ (частичный уникальный +// индекс idx_budget_envelope_active_chat, 00016), а не только кодом +// insertEnvelopeTx. Теста на это не было, и наблюдение «два active=true» на +// реплике (simpleAI-faeq.10, баг 2) читалось как поломка закрытия — хотя на +// деле конверты принадлежали РАЗНЫМ чатам, что по ADR-004 норма. Проверяем +// прямой вставкой мимо стора: второй активный в ОДНОМ чате обязан быть отвергнут. +func TestEnvelope_SecondActiveRejectedBySchema(t *testing.T) { + url := os.Getenv("BOTCLIENT_DATABASE_URL_RW") + if url == "" { + t.Skip("BOTCLIENT_DATABASE_URL_RW не задан — write-доступ к реплике недоступен") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + + const chatID = int64(-70003) + cleanup := func() { + if _, err := pool.Exec(ctx, "DELETE FROM budget_envelope WHERE chat_id = $1", chatID); err != nil { + t.Logf("cleanup: %v", err) + } + } + cleanup() + defer cleanup() + + from := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + to := from.AddDate(0, 0, 13) + insert := func() error { + _, err := pool.Exec(ctx, ` + INSERT INTO budget_envelope (chat_id, income_amount, income_currency, period_start, period_end) + VALUES ($1, 1000, 'RUB', $2, $3) + `, chatID, from, to) + return err + } + if err := insert(); err != nil { + t.Fatalf("первая вставка: %v", err) + } + if err := insert(); err == nil { + t.Fatal("второй active=true конверт в том же чате вставился — схема не держит правило «один активный»") + } +} diff --git a/internal/budget/model.go b/internal/budget/model.go index fcbc25f..7a87171 100644 --- a/internal/budget/model.go +++ b/internal/budget/model.go @@ -245,6 +245,12 @@ type Reminder struct { NotifyHour int // 0–23 NotifyMinute int // 0–59 Timezone string + // Утренний пуш с конвертами — отдельное расписание в той же строке: + // оператор может хотеть конверты по утрам, но не хотеть вечернего + // напоминания (и наоборот). Пояс общий — он у человека один. + EnvelopeEnabled bool + EnvelopeHour int // 0–23 + EnvelopeMinute int // 0–59 } // CategoryForecast — прогноз трат по одной категории в одной валюте на следующий период. @@ -302,6 +308,63 @@ type Envelope struct { CreatedAt time.Time } +// Виды долей конверта (ADR-008). +const ( + ShareKindSpend = "spend" // тратится в этом периоде + ShareKindSave = "save" // копится, остаток переносится в следующий приход + // ShareKindFixed — конверт под конкретный регулярный платёж: сумма и дата + // известны заранее, тратить из него нельзя, в дневной лимит он не входит. + // Категорий у такой доли нет: её факт — сам recurring-платёж, а транзакции + // с recurring_id в факт долей не попадают (ADR-008 §5). + ShareKindFixed = "fixed" +) + +// Источник суммы доли (ADR-008). +const ( + ShareSourceAuto = "auto" // посчитано из истории трат + ShareSourceOverride = "override" // поправлено оператором вручную +) + +// FallbackShareName — имя доли, в которую попадают траты без категории +// (category_id IS NULL) и категории, не привязанные ни к одной доле. +const FallbackShareName = "прочее" + +// EnvelopeShare — одна доля раскладки прихода (ADR-008). Суммы в THB. +type EnvelopeShare struct { + ID uuid.UUID + EnvelopeID uuid.UUID + Name string + Kind string // ShareKindSpend | ShareKindSave | ShareKindFixed + Allocated float64 // THB + CarriedIn float64 // THB, перенос с прошлого прихода + Source string // ShareSourceAuto | ShareSourceOverride + Position int + // DueDate — дата платежа у доли kind='fixed'; nil у остальных. Может лежать + // ЗА границей периода конверта: аренда платится 10.09, период кончается + // 06.09, а отложить деньги надо сейчас. + DueDate *time.Time + Categories []EnvelopeShareCategory +} + +// EnvelopeShareCategory — категория, отнесённая к доле. Хранится двойным +// ключом: CategoryID (может быть nil) и нормализованное имя в нижнем регистре, +// потому что фактический пайплайн трат name-keyed, а часть транзакций вообще +// без category_id. +type EnvelopeShareCategory struct { + CategoryID *uuid.UUID + CategoryName string // всегда в нижнем регистре +} + +// EnvelopeOverride — ручной лимит доли, заданный оператором. Живёт между +// приходами и к конкретному конверту не привязан. +type EnvelopeOverride struct { + ChatID int64 + ShareName string + Amount float64 + Currency string + UpdatedAt time.Time +} + // TopExpense — одна из топ-N самых дорогих расходных транзакций за период, // сконвертированная в THB. Используется AdvisorSkill action='analyze' для // передачи LLM детализированного среза трат. diff --git a/internal/budget/period.go b/internal/budget/period.go index 24af99e..7d2d725 100644 --- a/internal/budget/period.go +++ b/internal/budget/period.go @@ -27,15 +27,19 @@ func (h Horizon) Days() int { } // ResolveHorizon разрешает строку периода в границы и метку: -// - ”/'2weeks'/'2w' → [now, now+defaultDays] +// - ”/'2weeks'/'2w' → [now, now+defaultDays−1], то есть РОВНО defaultDays дней // - 'month' → [now, конец текущего месяца] // - 'YYYY-MM' → весь тот месяц // // defaultDays делает горизонт по умолчанию параметром, а не хардкодом. +// +// Граница включительная (Days() считает оба конца), поэтому конец периода — +// now+defaultDays−1: с now+defaultDays «две недели» длились пятнадцать дней, и +// дневной лимит делился на 15 вместо 14 (simpleAI-faeq.11). func ResolveHorizon(period string, now time.Time, defaultDays int) Horizon { switch strings.TrimSpace(strings.ToLower(period)) { case "", "2weeks", "2w": - return Horizon{Period{From: now, To: now.AddDate(0, 0, defaultDays)}, humanDays(defaultDays)} + return Horizon{Period{From: now, To: now.AddDate(0, 0, defaultDays-1)}, humanDays(defaultDays)} case "month": end := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, -1) return Horizon{Period{From: now, To: end}, "до конца месяца"} @@ -44,7 +48,7 @@ func ResolveHorizon(period string, now time.Time, defaultDays int) Horizon { start := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) return Horizon{Period{From: start, To: start.AddDate(0, 1, -1)}, period} } - return Horizon{Period{From: now, To: now.AddDate(0, 0, defaultDays)}, humanDays(defaultDays)} + return Horizon{Period{From: now, To: now.AddDate(0, 0, defaultDays-1)}, humanDays(defaultDays)} } // humanDays форматирует горизонт по умолчанию человекочитаемо. diff --git a/internal/budget/share_spent.go b/internal/budget/share_spent.go new file mode 100644 index 0000000..071b6dc --- /dev/null +++ b/internal/budget/share_spent.go @@ -0,0 +1,71 @@ +package budget + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" +) + +// Факт трат для остатка долей конверта (ADR-008 §8). Отдельный файл от +// store.go: у этого запроса своя семантика — он отдаёт СЫРЫЕ строки (категория +// + валюта + сумма), не сворачивая их в THB и не фильтруя по виду категории. +// Классификация и конвертация живут в чистой функции computeShareRemaining, +// иначе инвариант «трата в „Переводы“ не трогает ни одну долю» проверялся бы +// только интеграционным тестом с БД. + +// CategorySpentRow — одна строка факта: сколько потрачено по категории в одной +// валюте за период. CategoryID может быть nil (budget_transaction.category_id +// nullable, ADR-008 §6), поэтому имя категории обязательно. +type CategorySpentRow struct { + CategoryID *uuid.UUID + CategoryName string + Currency string + Amount float64 +} + +// SpentByCategoryExcludingRecurring — факт расходов за период БЕЗ транзакций, +// порождённых регулярными платежами (`recurring_id IS NOT NULL`). +// +// Исключение recurring — не оптимизация запроса, а инвариант недвойного учёта +// (ADR-007 §4, ADR-008 §5): такие траты уже вычтены как обязательства на этапе +// computeSafeToSpend, из которого и получена сумма к раскладке. Учесть их ещё +// и в факте доли значит списать одни и те же деньги дважды. Механизм тот же, +// что у дохода в GetRegularMonthlyIncomeAvg — фильтр прямо в SQL-агрегате. +// +// Оба ключа категории (id и имя) отдаются наверх, потому что матчинг траты к +// доле двухступенчатый — ResolveShare. +func (s *Store) SpentByCategoryExcludingRecurring(ctx context.Context, from, to time.Time) ([]CategorySpentRow, error) { + if to.Before(from) { + return nil, fmt.Errorf("SpentByCategoryExcludingRecurring: to (%s) раньше from (%s)", + to.Format("2006-01-02"), from.Format("2006-01-02")) + } + rows, err := s.pool.Query(ctx, ` + SELECT t.category_id, + COALESCE(c.name, '') AS category_name, + t.currency, + SUM(t.amount)::float8 AS total + FROM budget_transaction t + LEFT JOIN budget_category c ON c.id = t.category_id + WHERE t.type = 'expense' + AND t.recurring_id IS NULL + AND t.transaction_date >= $1::date + AND t.transaction_date <= $2::date + GROUP BY t.category_id, category_name, t.currency + `, from, to) + if err != nil { + return nil, fmt.Errorf("SpentByCategoryExcludingRecurring: %w", err) + } + defer rows.Close() + + var out []CategorySpentRow + for rows.Next() { + var r CategorySpentRow + if err := rows.Scan(&r.CategoryID, &r.CategoryName, &r.Currency, &r.Amount); err != nil { + return nil, fmt.Errorf("SpentByCategoryExcludingRecurring scan: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/internal/budget/share_spent_test.go b/internal/budget/share_spent_test.go new file mode 100644 index 0000000..2b68fcb --- /dev/null +++ b/internal/budget/share_spent_test.go @@ -0,0 +1,88 @@ +package budget + +import ( + "context" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestSpentByCategoryExcludingRecurring_Integration — инвариант ADR-008 §5 на +// реальном SQL: транзакция с recurring_id НЕ попадает в факт доли, обычная — +// попадает. Требует write-доступ (BOTCLIENT_DATABASE_URL_RW), иначе пропуск. +// +// Тест непустой по построению: обе транзакции лежат в одном периоде и в одной +// категории, отличаются ТОЛЬКО recurring_id. Убрать фильтр из запроса — и сумма +// станет 3300 вместо 1000; тест краснеет. +func TestSpentByCategoryExcludingRecurring_Integration(t *testing.T) { + url := os.Getenv("BOTCLIENT_DATABASE_URL_RW") + if url == "" { + t.Skip("BOTCLIENT_DATABASE_URL_RW не задан — write-доступ к реплике недоступен") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + s := NewStore(pool) + + // Окно в далёком прошлом — чтобы не пересечься с данными дампа. + from := time.Date(2001, 3, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(2001, 3, 28, 0, 0, 0, 0, time.UTC) + const chatID = int64(-70021) + + var catID uuid.UUID + var catName string + if err := pool.QueryRow(ctx, + `SELECT id, name FROM budget_category WHERE type = 'expense' ORDER BY sort_order LIMIT 1`). + Scan(&catID, &catName); err != nil { + t.Fatalf("нет ни одной расходной категории: %v", err) + } + + recID := uuid.New() + plainTx, recurringTx := uuid.New(), uuid.New() + cleanup := func() { + if _, err := pool.Exec(ctx, `DELETE FROM budget_transaction WHERE id = ANY($1)`, + []uuid.UUID{plainTx, recurringTx}); err != nil { + t.Logf("cleanup tx: %v", err) + } + if _, err := pool.Exec(ctx, `DELETE FROM budget_recurring WHERE id = $1`, recID); err != nil { + t.Logf("cleanup recurring: %v", err) + } + } + cleanup() + defer cleanup() + + if _, err := pool.Exec(ctx, ` + INSERT INTO budget_recurring (id, chat_id, name, type, amount, category_id, currency, recurrence_type, next_date) + VALUES ($1, $2, 'тест-подписка', 'expense', 2300, $3, 'THB', 'monthly', $4) + `, recID, chatID, catID, from); err != nil { + t.Fatalf("insert recurring: %v", err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO budget_transaction (id, recurring_id, type, amount, currency, category_id, description, transaction_date) + VALUES ($1, NULL, 'expense', 1000, 'THB', $2, 'тест: обычная трата', $3), + ($4, $5, 'expense', 2300, 'THB', $2, 'тест: платёж по подписке', $3) + `, plainTx, catID, from.AddDate(0, 0, 3), recurringTx, recID); err != nil { + t.Fatalf("insert transactions: %v", err) + } + + rows, err := s.SpentByCategoryExcludingRecurring(ctx, from, to) + if err != nil { + t.Fatalf("SpentByCategoryExcludingRecurring: %v", err) + } + var total float64 + for _, r := range rows { + if r.CategoryID != nil && *r.CategoryID == catID { + total += r.Amount + } + } + if total != 1000 { + t.Fatalf("факт по категории %q = %.2f, ожидалось 1000: recurring-платёж 2300 не должен прожигать долю (ADR-008 §5)", + catName, total) + } +} diff --git a/internal/budget/store.go b/internal/budget/store.go index 0a31f1f..4ad1efe 100644 --- a/internal/budget/store.go +++ b/internal/budget/store.go @@ -546,13 +546,40 @@ func (s *Store) SetReminder(ctx context.Context, r Reminder) error { return nil } +// SetEnvelopeReminder сохраняет расписание утреннего пуша с конвертами. +// Вечернее напоминание не трогает: у строки два независимых расписания, и +// включение конвертов не должно молча включать или гасить напоминание. +func (s *Store) SetEnvelopeReminder(ctx context.Context, r Reminder) error { + if r.Timezone == "" { + r.Timezone = "UTC" + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO budget_reminder (chat_id, enabled, notify_hour, notify_minute, timezone, + envelope_enabled, envelope_hour, envelope_minute, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) + ON CONFLICT (chat_id) DO UPDATE + SET envelope_enabled = EXCLUDED.envelope_enabled, + envelope_hour = EXCLUDED.envelope_hour, + envelope_minute = EXCLUDED.envelope_minute, + timezone = EXCLUDED.timezone, + updated_at = NOW() + `, r.ChatID, r.Enabled, r.NotifyHour, r.NotifyMinute, r.Timezone, + r.EnvelopeEnabled, r.EnvelopeHour, r.EnvelopeMinute) + if err != nil { + return fmt.Errorf("set envelope reminder: %w", err) + } + return nil +} + // GetReminder возвращает настройки напоминания для пользователя. Ошибка если не найден. func (s *Store) GetReminder(ctx context.Context, chatID int64) (*Reminder, error) { var r Reminder err := s.pool.QueryRow(ctx, ` - SELECT chat_id, enabled, notify_hour, notify_minute, timezone + SELECT chat_id, enabled, notify_hour, notify_minute, timezone, + envelope_enabled, envelope_hour, envelope_minute FROM budget_reminder WHERE chat_id = $1 - `, chatID).Scan(&r.ChatID, &r.Enabled, &r.NotifyHour, &r.NotifyMinute, &r.Timezone) + `, chatID).Scan(&r.ChatID, &r.Enabled, &r.NotifyHour, &r.NotifyMinute, &r.Timezone, + &r.EnvelopeEnabled, &r.EnvelopeHour, &r.EnvelopeMinute) if err != nil { return nil, fmt.Errorf("get reminder: %w", err) } @@ -562,8 +589,9 @@ func (s *Store) GetReminder(ctx context.Context, chatID int64) (*Reminder, error // ListActiveReminders возвращает все включённые напоминания. func (s *Store) ListActiveReminders(ctx context.Context) ([]Reminder, error) { rows, err := s.pool.Query(ctx, ` - SELECT chat_id, enabled, notify_hour, notify_minute, timezone - FROM budget_reminder WHERE enabled = true + SELECT chat_id, enabled, notify_hour, notify_minute, timezone, + envelope_enabled, envelope_hour, envelope_minute + FROM budget_reminder WHERE enabled = true OR envelope_enabled = true `) if err != nil { return nil, fmt.Errorf("list reminders: %w", err) @@ -573,7 +601,8 @@ func (s *Store) ListActiveReminders(ctx context.Context) ([]Reminder, error) { var out []Reminder for rows.Next() { var r Reminder - if err := rows.Scan(&r.ChatID, &r.Enabled, &r.NotifyHour, &r.NotifyMinute, &r.Timezone); err != nil { + if err := rows.Scan(&r.ChatID, &r.Enabled, &r.NotifyHour, &r.NotifyMinute, &r.Timezone, + &r.EnvelopeEnabled, &r.EnvelopeHour, &r.EnvelopeMinute); err != nil { return nil, fmt.Errorf("scan reminder: %w", err) } out = append(out, r) @@ -1007,30 +1036,109 @@ func (s *Store) CreateEnvelope(ctx context.Context, chatID int64, incomeAmount f } }() - if _, err := tx.Exec(ctx, `UPDATE budget_envelope SET active = false WHERE chat_id = $1 AND active`, chatID); err != nil { - return uuid.Nil, fmt.Errorf("CreateEnvelope deactivate: %w", err) + id, err := insertEnvelopeTx(ctx, tx, chatID, incomeAmount, currency, from, to, time.Now()) + if err != nil { + return uuid.Nil, err + } + if err := tx.Commit(ctx); err != nil { + return uuid.Nil, fmt.Errorf("CreateEnvelope commit: %w", err) + } + return id, nil +} + +// CreateEnvelopeWithShares сохраняет конверт ВМЕСТЕ с его раскладкой одной +// транзакцией (ADR-008): конверт без долей — состояние, в котором трате некуда +// падать (budget.ResolveShare вернёт nil), а «сколько осталось в конверте» не +// на что ответить. Две отдельные транзакции дают такое состояние при любой +// ошибке между ними, поэтому запись атомарна структурно, а не по договорённости. +func (s *Store) CreateEnvelopeWithShares( + ctx context.Context, + chatID int64, + incomeAmount float64, + currency string, + from, to time.Time, + shares []EnvelopeShare, + now time.Time, +) (uuid.UUID, error) { + if incomeAmount <= 0 { + return uuid.Nil, fmt.Errorf("CreateEnvelopeWithShares: income must be > 0") + } + if len(shares) == 0 { + return uuid.Nil, fmt.Errorf("CreateEnvelopeWithShares: пустая раскладка") + } + if currency == "" { + currency = "RUB" + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return uuid.Nil, fmt.Errorf("CreateEnvelopeWithShares begin: %w", err) + } + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + slog.Warn("CreateEnvelopeWithShares rollback", "err", rbErr) + } + }() + + id, err := insertEnvelopeTx(ctx, tx, chatID, incomeAmount, currency, from, to, now) + if err != nil { + return uuid.Nil, err + } + if err := insertSharesTx(ctx, tx, id, shares); err != nil { + return uuid.Nil, err + } + if err := tx.Commit(ctx); err != nil { + return uuid.Nil, fmt.Errorf("CreateEnvelopeWithShares commit: %w", err) + } + return id, nil +} + +// insertEnvelopeTx — деактивация прошлого активного конверта + вставка нового +// внутри уже открытой транзакции. Вынесено, чтобы CreateEnvelope и +// CreateEnvelopeWithShares писали конверт ОДНИМ кодом: расхождение здесь +// означало бы два разных конверта в зависимости от точки входа. +func insertEnvelopeTx(ctx context.Context, tx pgx.Tx, chatID int64, incomeAmount float64, currency string, from, to time.Time, now time.Time) (uuid.UUID, error) { + // Закрытие прошлого конверта: period_end := now − 1 день (ADR-008 §10). + // Не now: все даты в схеме — DATE, а periodSnapshotQuery фильтрует границы + // ВКЛЮЧИТЕЛЬНО, поэтому при period_end = now траты дня переключения попали + // бы разом в старый конверт (по верхней границе) и в новый (по нижней). + // + // LEAST — чтобы не удлинить уже истёкший конверт: у него period_end в + // прошлом, и присвоение now−1 задним числом втянуло бы в него чужие траты. + // GREATEST — обрезка до period_start для конверта, заведённого и закрытого + // в один день: период отрицательной длины GetPeriodSnapshot не принимает. + if _, err := tx.Exec(ctx, ` + UPDATE budget_envelope + SET active = false, + period_end = GREATEST(LEAST(period_end, $2::date - 1), period_start) + WHERE chat_id = $1 AND active + `, chatID, now); err != nil { + return uuid.Nil, fmt.Errorf("envelope deactivate: %w", err) } var id uuid.UUID - err = tx.QueryRow(ctx, ` + err := tx.QueryRow(ctx, ` INSERT INTO budget_envelope (chat_id, income_amount, income_currency, period_start, period_end) VALUES ($1, $2, $3, $4, $5) RETURNING id `, chatID, incomeAmount, currency, from, to).Scan(&id) if err != nil { - return uuid.Nil, fmt.Errorf("CreateEnvelope insert: %w", err) - } - if err := tx.Commit(ctx); err != nil { - return uuid.Nil, fmt.Errorf("CreateEnvelope commit: %w", err) + return uuid.Nil, fmt.Errorf("envelope insert: %w", err) } return id, nil } // GetActiveEnvelope возвращает активный конверт chat'а (ok=false если нет). +// +// ORDER BY — страховка, а не украшение: активный конверт в чате один +// структурно (частичный уникальный индекс idx_budget_envelope_active_chat, +// 00016), но LIMIT 1 без порядка превращает любое нарушение этого правила в +// НЕДЕТЕРМИНИРОВАННЫЙ выбор конверта, и перенос накоплений уходил бы из +// случайного. Свежий конверт побеждает всегда. func (s *Store) GetActiveEnvelope(ctx context.Context, chatID int64) (*Envelope, bool, error) { var e Envelope err := s.pool.QueryRow(ctx, ` SELECT id, chat_id, income_amount, income_currency, period_start, period_end, created_at FROM budget_envelope WHERE chat_id = $1 AND active + ORDER BY created_at DESC, id DESC LIMIT 1 `, chatID).Scan(&e.ID, &e.ChatID, &e.IncomeAmount, &e.IncomeCurrency, &e.PeriodStart, &e.PeriodEnd, &e.CreatedAt) if err != nil { @@ -1042,6 +1150,351 @@ func (s *Store) GetActiveEnvelope(ctx context.Context, chatID int64) (*Envelope, return &e, true, nil } +// --- Доли конверта (ADR-008) --- + +// NormalizeName приводит имя доли/категории к каноничному виду ключа: обрезка +// пробелов + нижний регистр. Имена категорий регистрозависимы в уникальном +// индексе budget_category(name,type), а FindCategory ищет по LOWER(name) — +// поэтому ключом везде служит нормализованная форма. +// +// Экспортируется намеренно и единственная в проекте: ключ доли в раскладке и +// ключ доли в сторе обязаны совпадать посимвольно, а вторая копия +// strings.ToLower(strings.TrimSpace(...)) рано или поздно разъедется с этой. +func NormalizeName(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +// CreateShares сохраняет раскладку прихода по долям вместе с категориями долей +// одной транзакцией: частичная раскладка (доли без категорий) хуже отсутствия +// раскладки — по ней траты разойдутся не туда. +func (s *Store) CreateShares(ctx context.Context, envelopeID uuid.UUID, shares []EnvelopeShare) error { + if envelopeID == uuid.Nil { + return fmt.Errorf("CreateShares: envelopeID пуст") + } + if len(shares) == 0 { + return nil + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("CreateShares begin: %w", err) + } + defer func() { + if rbErr := tx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + slog.Warn("CreateShares rollback", "err", rbErr) + } + }() + + if err := insertSharesTx(ctx, tx, envelopeID, shares); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("CreateShares commit: %w", err) + } + return nil +} + +// insertSharesTx вставляет доли и их категории внутри уже открытой транзакции. +// Единый код записи раскладки для CreateShares и CreateEnvelopeWithShares. +func insertSharesTx(ctx context.Context, tx pgx.Tx, envelopeID uuid.UUID, shares []EnvelopeShare) error { + for i, sh := range shares { + name := strings.TrimSpace(sh.Name) + if name == "" { + return fmt.Errorf("доля #%d без имени", i) + } + position := sh.Position + if position == 0 { + position = i + } + var shareID uuid.UUID + err := tx.QueryRow(ctx, ` + INSERT INTO budget_envelope_share + (envelope_id, name, kind, allocated, carried_in, source, position, due_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id + `, envelopeID, name, sh.Kind, sh.Allocated, sh.CarriedIn, sh.Source, position, sh.DueDate).Scan(&shareID) + if err != nil { + return fmt.Errorf("CreateShares insert share %q: %w", name, err) + } + for _, c := range sh.Categories { + catName := NormalizeName(c.CategoryName) + if catName == "" { + continue + } + if _, err := tx.Exec(ctx, ` + INSERT INTO budget_envelope_share_category (share_id, category_id, category_name) + VALUES ($1, $2, $3) + ON CONFLICT (share_id, category_name) DO UPDATE SET category_id = EXCLUDED.category_id + `, shareID, c.CategoryID, catName); err != nil { + return fmt.Errorf("insert category %q: %w", catName, err) + } + } + } + return nil +} + +// ListShares возвращает доли конверта вместе с их категориями. Фильтрация по +// chat_id — через join на budget_envelope: доли своего chat_id не имеют, и без +// join чужой envelope_id прочитал бы чужую раскладку (ADR-004 изоляция). +func (s *Store) ListShares(ctx context.Context, chatID int64, envelopeID uuid.UUID) ([]EnvelopeShare, error) { + rows, err := s.pool.Query(ctx, ` + SELECT sh.id, sh.envelope_id, sh.name, sh.kind, sh.allocated, sh.carried_in, sh.source, sh.position, sh.due_date + FROM budget_envelope_share sh + JOIN budget_envelope e ON e.id = sh.envelope_id + WHERE e.id = $1 AND e.chat_id = $2 + ORDER BY sh.position, sh.name + `, envelopeID, chatID) + if err != nil { + return nil, fmt.Errorf("ListShares: %w", err) + } + defer rows.Close() + + var out []EnvelopeShare + byID := map[uuid.UUID]int{} + for rows.Next() { + var sh EnvelopeShare + if err := rows.Scan(&sh.ID, &sh.EnvelopeID, &sh.Name, &sh.Kind, &sh.Allocated, &sh.CarriedIn, &sh.Source, &sh.Position, &sh.DueDate); err != nil { + return nil, fmt.Errorf("ListShares scan: %w", err) + } + byID[sh.ID] = len(out) + out = append(out, sh) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("ListShares rows: %w", err) + } + if len(out) == 0 { + return nil, nil + } + + catRows, err := s.pool.Query(ctx, ` + SELECT sc.share_id, sc.category_id, sc.category_name + FROM budget_envelope_share_category sc + JOIN budget_envelope_share sh ON sh.id = sc.share_id + JOIN budget_envelope e ON e.id = sh.envelope_id + WHERE e.id = $1 AND e.chat_id = $2 + ORDER BY sc.category_name + `, envelopeID, chatID) + if err != nil { + return nil, fmt.Errorf("ListShares categories: %w", err) + } + defer catRows.Close() + + for catRows.Next() { + var shareID uuid.UUID + var c EnvelopeShareCategory + if err := catRows.Scan(&shareID, &c.CategoryID, &c.CategoryName); err != nil { + return nil, fmt.Errorf("ListShares scan category: %w", err) + } + if idx, ok := byID[shareID]; ok { + out[idx].Categories = append(out[idx].Categories, c) + } + } + if err := catRows.Err(); err != nil { + return nil, fmt.Errorf("ListShares category rows: %w", err) + } + return out, nil +} + +// SetOverride сохраняет ручной лимит доли. Ключ — нормализованное имя доли: +// лимит переживает конверт и находится по имени при следующем приходе. +func (s *Store) SetOverride(ctx context.Context, chatID int64, shareName string, amount float64, currency string) error { + name := NormalizeName(shareName) + if name == "" { + return fmt.Errorf("SetOverride: пустое имя доли") + } + if currency == "" { + currency = "THB" + } + _, err := s.pool.Exec(ctx, ` + INSERT INTO budget_envelope_limit_override (chat_id, share_name, amount, currency, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (chat_id, share_name) + DO UPDATE SET amount = EXCLUDED.amount, currency = EXCLUDED.currency, updated_at = now() + `, chatID, name, amount, currency) + if err != nil { + return fmt.Errorf("SetOverride: %w", err) + } + return nil +} + +// ListOverrides возвращает ручные лимиты chat'а. ShareName — нормализованный, +// сравнивать с именами долей нужно через NormalizeName. +func (s *Store) ListOverrides(ctx context.Context, chatID int64) ([]EnvelopeOverride, error) { + rows, err := s.pool.Query(ctx, ` + SELECT chat_id, share_name, amount, currency, updated_at + FROM budget_envelope_limit_override + WHERE chat_id = $1 + ORDER BY share_name + `, chatID) + if err != nil { + return nil, fmt.Errorf("ListOverrides: %w", err) + } + defer rows.Close() + + var out []EnvelopeOverride + for rows.Next() { + var o EnvelopeOverride + if err := rows.Scan(&o.ChatID, &o.ShareName, &o.Amount, &o.Currency, &o.UpdatedAt); err != nil { + return nil, fmt.Errorf("ListOverrides scan: %w", err) + } + out = append(out, o) + } + return out, rows.Err() +} + +// DeleteOverride снимает ручной лимит: доля снова считается из истории трат. +func (s *Store) DeleteOverride(ctx context.Context, chatID int64, shareName string) error { + name := NormalizeName(shareName) + if name == "" { + return fmt.Errorf("DeleteOverride: пустое имя доли") + } + if _, err := s.pool.Exec(ctx, ` + DELETE FROM budget_envelope_limit_override WHERE chat_id = $1 AND share_name = $2 + `, chatID, name); err != nil { + return fmt.Errorf("DeleteOverride: %w", err) + } + return nil +} + +// CategoryHistoryMonths возвращает глубину истории по категориям: сколько +// ПОЛНЫХ календарных месяцев содержат траты этой категории. Ключ — +// нормализованное имя категории (тот же ключ, что у долей). +// +// Текущий месяц исключён намеренно: он неполный, и раскладка, поверившая ему +// как «месяцу данных», назначила бы лимит по обрезку. Окно — те же months, что +// у GetForecastData: глубина истории должна измеряться по ТОЙ ЖЕ выборке, из +// которой считается прогноз, иначе лимит назначается по одним данным, а +// допуск к нему проверяется по другим. +func (s *Store) CategoryHistoryMonths(ctx context.Context, months int) (map[string]int, error) { + var fromClause string + var args []any + if months > 0 { + fromClause = `AND t.transaction_date >= date_trunc('month', NOW()) - ($1 * INTERVAL '1 month')` + args = append(args, months) + } + query := fmt.Sprintf(` + SELECT COALESCE(c.name, 'Прочее') AS category_name, + COUNT(DISTINCT date_trunc('month', t.transaction_date))::int AS months + FROM budget_transaction t + LEFT JOIN budget_category c ON c.id = t.category_id + WHERE t.type = 'expense' + AND t.transaction_date < date_trunc('month', NOW()) + %s + GROUP BY category_name + `, fromClause) + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("CategoryHistoryMonths: %w", err) + } + defer rows.Close() + + out := map[string]int{} + for rows.Next() { + var name string + var n int + if err := rows.Scan(&name, &n); err != nil { + return nil, fmt.Errorf("CategoryHistoryMonths scan: %w", err) + } + out[NormalizeName(name)] = n + } + return out, rows.Err() +} + +// SpentOutsideShares — факт трат за период, который НЕ ложится ни в одну долю +// конверта (строка «вне конвертов», ADR-008 §4). Это два слагаемых: +// +// - траты по ФИКСИРОВАННЫМ и непотребительским категориям (аренда, подписки, +// переводы) — доли строятся только по переменным ежедневным тратам; +// - траты по переменным категориям, порождённые recurring-платежом +// (recurring_id IS NOT NULL) — они уже вычтены как обязательства на этапе +// computeSafeToSpend, и учитывать их ещё и в доле значило бы посчитать +// дважды (ADR-008 §5). +// +// Классификация категории — единый доменный IsVariableDailyExpense, тот же, по +// которому строится прогноз и раскладка. Сумма в THB. +func (s *Store) SpentOutsideShares(ctx context.Context, from, to time.Time, rates map[string]float64) (float64, error) { + if to.Before(from) { + return 0, fmt.Errorf("SpentOutsideShares: to (%s) раньше from (%s)", to.Format("2006-01-02"), from.Format("2006-01-02")) + } + rows, err := s.pool.Query(ctx, ` + SELECT COALESCE(c.name, 'Прочее') AS category_name, + t.currency, + (t.recurring_id IS NOT NULL) AS is_recurring, + SUM(t.amount)::float8 AS total + FROM budget_transaction t + LEFT JOIN budget_category c ON c.id = t.category_id + WHERE t.type = 'expense' + AND t.transaction_date >= $1::date + AND t.transaction_date <= $2::date + GROUP BY category_name, t.currency, is_recurring + `, from, to) + if err != nil { + return 0, fmt.Errorf("SpentOutsideShares: %w", err) + } + defer rows.Close() + + var total float64 + for rows.Next() { + var category, currency string + var isRecurring bool + var amount float64 + if err := rows.Scan(&category, ¤cy, &isRecurring, &amount); err != nil { + return 0, fmt.Errorf("SpentOutsideShares scan: %w", err) + } + if IsVariableDailyExpense(category) && !isRecurring { + continue // это факт доли, а не «вне конвертов» + } + thb, ok := ToTHB(amount, currency, rates) + if !ok { + continue // курса нет — молча раздувать сумму нельзя + } + total += thb + } + return total, rows.Err() +} + +// ResolveShare определяет, в какую долю попадает трата (ADR-008). +// +// Порядок: по category_id, если он у транзакции есть; иначе — по +// нормализованному имени категории. Если по id не нашлось, имя всё равно +// проверяется: доля могла быть заведена по имени категории, у которой на тот +// момент не было строки в budget_category. Ничего не сматчилось (в том числе +// category_id IS NULL и пустое имя) — трата уходит в fallback-долю «прочее». +// Fallback-доли нет — возвращается nil: вызывающий сам решает, что делать с +// нераспределённой тратой, молча ронять её в первую попавшуюся долю нельзя. +func ResolveShare(shares []EnvelopeShare, categoryID *uuid.UUID, categoryName string) *EnvelopeShare { + if categoryID != nil && *categoryID != uuid.Nil { + for i := range shares { + for _, c := range shares[i].Categories { + if c.CategoryID != nil && *c.CategoryID == *categoryID { + return &shares[i] + } + } + } + } + if name := NormalizeName(categoryName); name != "" { + for i := range shares { + for _, c := range shares[i].Categories { + if NormalizeName(c.CategoryName) == name { + return &shares[i] + } + } + } + } + return FallbackShare(shares) +} + +// FallbackShare возвращает долю «прочее» — приёмник трат без категории и +// категорий, не привязанных ни к одной доле. nil, если такой доли нет. +func FallbackShare(shares []EnvelopeShare) *EnvelopeShare { + for i := range shares { + if NormalizeName(shares[i].Name) == FallbackShareName { + return &shares[i] + } + } + return nil +} + func (s *Store) getMonthlyExpenses(ctx context.Context, months int) ([]MonthlyCategoryExpense, error) { var fromClause string var args []any diff --git a/internal/db/migrations/00017_budget_envelope_shares.sql b/internal/db/migrations/00017_budget_envelope_shares.sql new file mode 100644 index 0000000..dd2ec5c --- /dev/null +++ b/internal/db/migrations/00017_budget_envelope_shares.sql @@ -0,0 +1,62 @@ +-- +goose Up +-- +goose StatementBegin +-- Раскладка прихода по конвертам-долям (ADR-008). Живой конверт (00016) хранит +-- САМ приход и горизонт; здесь — на какие доли этот приход разложен. +-- +-- ВАЖНО: все денежные суммы в этих таблицах хранятся в THB — валюта расчётной +-- базы (история трат агрегируется в THB). Приход в budget_envelope может быть +-- в любой валюте (income_currency), доли — всегда THB. +CREATE TABLE budget_envelope_share ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + envelope_id UUID NOT NULL REFERENCES budget_envelope(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('spend', 'save')), + allocated NUMERIC(12,2) NOT NULL, -- THB + carried_in NUMERIC(12,2) NOT NULL DEFAULT 0, -- THB, перенос с прошлого прихода + source TEXT NOT NULL CHECK (source IN ('auto', 'override')), + position INT NOT NULL, + -- Имя доли — ключ переноса накоплений между приходами: остаток конверта + -- «Отпуск» из прошлого периода ищется в новом ПО ИМЕНИ. Два конверта с + -- одинаковым именем в одном приходе делают перенос неоднозначным, поэтому + -- уникальность — структурная, а не проверка в коде. + UNIQUE (envelope_id, name) +); + +CREATE INDEX idx_budget_envelope_share_envelope + ON budget_envelope_share(envelope_id); + +-- Категории, которые попадают в долю. Двойной ключ (id + нормализованное имя) — +-- потому что фактический пайплайн трат name-keyed (getMonthlyExpenses группирует +-- по COALESCE(c.name,'Прочее')), но уникальный индекс категорий регистрозависим +-- по (name,type), а у части транзакций category_id IS NULL. Матчинг: сначала по +-- category_id, если он есть; иначе по lower(category_name). +CREATE TABLE budget_envelope_share_category ( + share_id UUID NOT NULL REFERENCES budget_envelope_share(id) ON DELETE CASCADE, + category_id UUID REFERENCES budget_category(id), + category_name TEXT NOT NULL, -- всегда в нижнем регистре + PRIMARY KEY (share_id, category_name) +); + +CREATE INDEX idx_budget_envelope_share_category_cat + ON budget_envelope_share_category(category_id) + WHERE category_id IS NOT NULL; + +-- Ручные лимиты, которые оператор поправил словами. Живут МЕЖДУ приходами и к +-- конкретному конверту не привязаны: новый приход раскладывается уже с их +-- учётом (source='override' у соответствующей доли). chat-scoped (ADR-004). +CREATE TABLE budget_envelope_limit_override ( + chat_id BIGINT NOT NULL, + share_name TEXT NOT NULL, + amount NUMERIC(12,2) NOT NULL, + currency TEXT NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + PRIMARY KEY (chat_id, share_name) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS budget_envelope_limit_override; +DROP TABLE IF EXISTS budget_envelope_share_category; +DROP TABLE IF EXISTS budget_envelope_share; +-- +goose StatementEnd diff --git a/internal/db/migrations/00018_budget_reminder_envelope.sql b/internal/db/migrations/00018_budget_reminder_envelope.sql new file mode 100644 index 0000000..5d9dfcc --- /dev/null +++ b/internal/db/migrations/00018_budget_reminder_envelope.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE budget_reminder + ADD COLUMN IF NOT EXISTS envelope_enabled BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS envelope_hour INT NOT NULL DEFAULT 8 CHECK (envelope_hour >= 0 AND envelope_hour <= 23), + ADD COLUMN IF NOT EXISTS envelope_minute INT NOT NULL DEFAULT 0 CHECK (envelope_minute >= 0 AND envelope_minute <= 59); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE budget_reminder + DROP COLUMN IF EXISTS envelope_enabled, + DROP COLUMN IF EXISTS envelope_hour, + DROP COLUMN IF EXISTS envelope_minute; +-- +goose StatementEnd diff --git a/internal/db/migrations/00019_envelope_share_fixed.sql b/internal/db/migrations/00019_envelope_share_fixed.sql new file mode 100644 index 0000000..f78cf05 --- /dev/null +++ b/internal/db/migrations/00019_envelope_share_fixed.sql @@ -0,0 +1,44 @@ +-- +goose Up +-- +goose StatementBegin +-- Регулярные платежи становятся ВИДИМЫМИ конвертами (simpleAI-faeq.11). +-- +-- До этого обязательства вычитались из прихода ДО раскладки и в ответе бота +-- жили одной сводной строкой «обязательства 12332». Оператор такой ответ +-- разнёс: приход визуально не сходится, а деление трат на «обязательные» и +-- «на жизнь» он отверг прямо («есть мне тоже надо, или ты считаешь что еда +-- необязательна?»). Ресёрч подтверждает: YNAB держит обязательные платежи +-- первыми КАТЕГОРИЯМИ плана, а не скрытым вычетом. +-- +-- kind='fixed' — доля под конкретный платёж из budget_recurring: сумма и дата +-- известны заранее, тратить из неё нельзя, и в дневной лимит она не входит. +-- Категорий у такой доли нет намеренно: факт по ней — сам recurring-платёж, +-- а транзакции с recurring_id в факт долей не попадают (ADR-008 §5). +ALTER TABLE budget_envelope_share + DROP CONSTRAINT IF EXISTS budget_envelope_share_kind_check; + +ALTER TABLE budget_envelope_share + ADD CONSTRAINT budget_envelope_share_kind_check + CHECK (kind IN ('spend', 'save', 'fixed')); + +-- Дата платежа. NULL у гибких долей и накоплений — у них даты нет. +-- Может лежать ЗА границей периода конверта: аренда платится 10.09, период +-- кончается 06.09, а отложить деньги надо сейчас. +ALTER TABLE budget_envelope_share + ADD COLUMN IF NOT EXISTS due_date DATE; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- Доли kind='fixed' в старую схему не влезают — сносим их вместе с категориями +-- (каскад по share_id) ДО сужения CHECK, иначе ADD CONSTRAINT упадёт. +DELETE FROM budget_envelope_share WHERE kind = 'fixed'; + +ALTER TABLE budget_envelope_share DROP COLUMN IF EXISTS due_date; + +ALTER TABLE budget_envelope_share + DROP CONSTRAINT IF EXISTS budget_envelope_share_kind_check; + +ALTER TABLE budget_envelope_share + ADD CONSTRAINT budget_envelope_share_kind_check + CHECK (kind IN ('spend', 'save')); +-- +goose StatementEnd diff --git a/internal/db/migrations_test.go b/internal/db/migrations_test.go new file mode 100644 index 0000000..484132a --- /dev/null +++ b/internal/db/migrations_test.go @@ -0,0 +1,35 @@ +package db + +import ( + "strings" + "testing" +) + +// Goose ключует применённые миграции по НОМЕРУ версии, а не по имени файла. +// Два файла с одним номером — молчаливая потеря одной из них на проде: +// применится первая, вторая навсегда останется «уже применённой». +func TestMigrations_VersionsAreUnique(t *testing.T) { + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + t.Fatalf("read migrations: %v", err) + } + + seen := make(map[string]string, len(entries)) + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".sql") { + continue + } + version, _, ok := strings.Cut(name, "_") + if !ok { + t.Fatalf("миграция без номера версии: %s", name) + } + if prev, dup := seen[version]; dup { + t.Fatalf("дубль версии %s: %s и %s", version, prev, name) + } + seen[version] = name + } + if len(seen) == 0 { + t.Fatal("миграции не найдены — тест ничего не проверяет") + } +} diff --git a/internal/envelopepush/source.go b/internal/envelopepush/source.go new file mode 100644 index 0000000..b0896d6 --- /dev/null +++ b/internal/envelopepush/source.go @@ -0,0 +1,70 @@ +// Package envelopepush собирает тело утреннего пуша с конвертами. +// +// Отдельный пакет, а не метод скилла, ровно по одной причине: тело берётся у +// существующего показа конвертов (safe_to_spend), и переписывать его формат +// здесь нельзя — формат живёт в скилле и меняется там. Пакет только решает, +// ЕСТЬ ли что слать, и просит скилл напечатать то же, что оператор видит по +// запросу «сколько в конвертах». +package envelopepush + +import ( + "context" + "strings" + "time" + + "simpleAI/internal/agent" + "simpleAI/internal/budget" +) + +// MorningHeader — шапка утреннего пуша. Тело под ней — вывод скилла как есть. +const MorningHeader = "☀️ Доброе утро! Конверты на сегодня:" + +// envelopeStore — проверка, что раскладка вообще существует. +type envelopeStore interface { + GetActiveEnvelope(ctx context.Context, chatID int64) (*budget.Envelope, bool, error) +} + +// sharesSkill — показ конвертов (safe_to_spend). Берём его через plugin.Skill- +// совместимый Run, чтобы не зависеть от внутренних методов скилла. +type sharesSkill interface { + Run(ctx context.Context, input string) (string, error) +} + +// Source реализует notify.EnvelopeSource. +type Source struct { + store envelopeStore + skill sharesSkill +} + +// New собирает источник утренних конвертов. +func New(store envelopeStore, skill sharesSkill) *Source { + return &Source{store: store, skill: skill} +} + +// sharesRequest — тот же запрос, что оператор задаёт словами: без суммы и со +// словом «конверты» скилл уходит в режим остатка по долям. +const sharesRequest = `{"question":"сколько осталось в конвертах"}` + +// MorningEnvelopes возвращает тело пуша. Пустая строка = слать нечего: +// активного конверта нет либо показ ничего не напечатал. +func (s *Source) MorningEnvelopes(ctx context.Context, chatID int64, _ *time.Location) (string, error) { + // Проверка конверта здесь, а не по тексту ответа: скилл на отсутствие + // раскладки отвечает связной фразой («Активного конверта нет…»), и пуш + // каждое утро повторял бы её вместо молчания. + _, ok, err := s.store.GetActiveEnvelope(ctx, chatID) + if err != nil { + return "", err + } + if !ok { + return "", nil + } + + body, err := s.skill.Run(context.WithValue(ctx, agent.ChatIDKey{}, chatID), sharesRequest) + if err != nil { + return "", err + } + if strings.TrimSpace(body) == "" { + return "", nil + } + return MorningHeader + "\n\n" + body, nil +} diff --git a/internal/envelopepush/source_test.go b/internal/envelopepush/source_test.go new file mode 100644 index 0000000..c443557 --- /dev/null +++ b/internal/envelopepush/source_test.go @@ -0,0 +1,91 @@ +package envelopepush + +import ( + "context" + "errors" + "strings" + "testing" + + "simpleAI/internal/agent" + "simpleAI/internal/budget" +) + +type fakeStore struct { + ok bool + err error +} + +func (f fakeStore) GetActiveEnvelope(_ context.Context, _ int64) (*budget.Envelope, bool, error) { + if f.err != nil { + return nil, false, f.err + } + if !f.ok { + return nil, false, nil + } + return &budget.Envelope{}, true, nil +} + +type fakeSkill struct { + out string + err error + calls int + sawChatID int64 + sawRequest string +} + +func (f *fakeSkill) Run(ctx context.Context, input string) (string, error) { + f.calls++ + f.sawRequest = input + if id, ok := ctx.Value(agent.ChatIDKey{}).(int64); ok { + f.sawChatID = id + } + return f.out, f.err +} + +// Нет активного конверта → тела нет и скилл даже не дёргается. +func TestMorningEnvelopes_NoActiveEnvelope(t *testing.T) { + skill := &fakeSkill{out: "Активного конверта нет."} + body, err := New(fakeStore{ok: false}, skill).MorningEnvelopes(context.Background(), 42, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if body != "" { + t.Fatalf("want empty body, got %q", body) + } + if skill.calls != 0 { + t.Fatalf("skill must not be called without an envelope, got %d calls", skill.calls) + } +} + +// Конверт есть → тело от скилла, chat_id доехал в контексте. +func TestMorningEnvelopes_ActiveEnvelope(t *testing.T) { + skill := &fakeSkill{out: "🍚 Еда: 5000 ฿"} + body, err := New(fakeStore{ok: true}, skill).MorningEnvelopes(context.Background(), 42, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if !strings.Contains(body, "Еда") || !strings.HasPrefix(body, MorningHeader) { + t.Fatalf("unexpected body: %q", body) + } + if skill.sawChatID != 42 { + t.Fatalf("chat_id not propagated: %d", skill.sawChatID) + } + if !strings.Contains(skill.sawRequest, "конверт") { + t.Fatalf("request must ask for envelopes: %q", skill.sawRequest) + } +} + +// Пустой вывод скилла — не повод слать одну шапку. +func TestMorningEnvelopes_EmptySkillOutput(t *testing.T) { + body, err := New(fakeStore{ok: true}, &fakeSkill{out: " "}).MorningEnvelopes(context.Background(), 42, nil) + if err != nil || body != "" { + t.Fatalf("want empty body without error, got %q / %v", body, err) + } +} + +func TestMorningEnvelopes_StoreError(t *testing.T) { + _, err := New(fakeStore{err: errors.New("boom")}, &fakeSkill{}).MorningEnvelopes(context.Background(), 42, nil) + if err == nil { + t.Fatal("want error from store") + } +} diff --git a/internal/notify/envelope_command.go b/internal/notify/envelope_command.go new file mode 100644 index 0000000..35a22bf --- /dev/null +++ b/internal/notify/envelope_command.go @@ -0,0 +1,127 @@ +package notify + +import ( + "context" + "database/sql" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + "simpleAI/internal/budget" +) + +// DefaultEnvelopeHour — час утреннего пуша, когда оператор не назвал время. +const DefaultEnvelopeHour = 8 + +// DefaultEnvelopeTimezone — пояс по умолчанию: оператор живёт в Таиланде, +// доход считает в рублях. Пояс UTC давал бы пуш посреди ночи. +const DefaultEnvelopeTimezone = "Asia/Bangkok" + +// EnvelopeCommand — распознанная команда управления утренней рассылкой конвертов. +type EnvelopeCommand struct { + Enable bool + HasTime bool // время названо явно; иначе Hour/Minute не заполнены + Hour int + Minute int +} + +// EnvelopeReminderStore — минимум, нужный для включения/выключения рассылки. +type EnvelopeReminderStore interface { + GetReminder(ctx context.Context, chatID int64) (*budget.Reminder, error) + SetEnvelopeReminder(ctx context.Context, r budget.Reminder) error +} + +// Границу слова пишем через класс «не буква» вручную: \b в RE2 — ASCII-only, +// с кириллицей он не срабатывает вовсе (первая версия парсера молча не узнавала +// ни одной фразы оператора). +var ( + // «в 7:30», «в 7.30», «в 9» — время после предлога, чтобы не подобрать сумму из соседней фразы. + envelopeTimeRe = regexp.MustCompile(`(?:^|[^\p{L}])в\s+(\d{1,2})(?:[:.](\d{2}))?(?:[^\d]|$)`) + negationRe = regexp.MustCompile(`(?:^|[^\p{L}])(не|выключи|отключи|перестань|прекрати|хватит)(?:[^\p{L}]|$)`) + positiveRe = regexp.MustCompile(`(?:^|[^\p{L}])(присылай|шли|высылай|включи|напоминай|показывай|отправляй)(?:[^\p{L}]|$)`) +) + +// ParseEnvelopeCommand разбирает фразу оператора вида «присылай конверты по утрам [в 7:30]» +// или «не присылай конверты». Второй результат false — фраза не про расписание конвертов. +// +// Разбор детерминированный, мимо LLM: включение и выключение рассылки не должно +// зависеть от того, угадала ли модель нужный action — цена ошибки здесь +// молчаливая (пуш просто не приходит, и оператор об этом не узнает). +func ParseEnvelopeCommand(text string) (EnvelopeCommand, bool) { + t := strings.ToLower(strings.TrimSpace(text)) + if t == "" || !strings.Contains(t, "конверт") { + return EnvelopeCommand{}, false + } + + morning := strings.Contains(t, "утр") // «по утрам», «утром», «утренние» + neg := negationRe.MatchString(t) + pos := positiveRe.MatchString(t) + + switch { + case neg && (pos || morning): + // «не присылай конверты» — про утро можно и не упоминать. + return EnvelopeCommand{Enable: false}, true + case pos && morning: + cmd := EnvelopeCommand{Enable: true} + if m := envelopeTimeRe.FindStringSubmatch(t); m != nil { + h, err := strconv.Atoi(m[1]) + if err != nil || h > 23 { + return cmd, true + } + min := 0 + if m[2] != "" { + min, err = strconv.Atoi(m[2]) + if err != nil || min > 59 { + return cmd, true + } + } + cmd.HasTime, cmd.Hour, cmd.Minute = true, h, min + } + return cmd, true + default: + return EnvelopeCommand{}, false + } +} + +// ApplyEnvelopeCommand сохраняет настройку и возвращает ответ оператору. +// defaultTZ используется, только если у чата ещё нет сохранённого пояса. +func ApplyEnvelopeCommand(ctx context.Context, store EnvelopeReminderStore, chatID int64, cmd EnvelopeCommand, defaultTZ string) (string, error) { + if defaultTZ == "" { + defaultTZ = "UTC" + } + + r := budget.Reminder{ChatID: chatID, Timezone: defaultTZ, EnvelopeHour: DefaultEnvelopeHour} + // Строки может ещё не быть — её создаёт первое включение, и падать из-за + // её отсутствия нельзя. А вот ЛЮБАЯ другая ошибка чтения — это неизвестное + // состояние: записать поверх дефолты значит стереть вечернее напоминание + // оператора (Enabled/NotifyHour/NotifyMinute/Timezone) из-за моргнувшей БД. + cur, err := store.GetReminder(ctx, chatID) + switch { + case err != nil && !errors.Is(err, sql.ErrNoRows): + return "", fmt.Errorf("get reminder: %w", err) + case err == nil && cur != nil: + r = *cur + if r.Timezone == "" { + r.Timezone = defaultTZ + } + if !r.EnvelopeEnabled && r.EnvelopeHour == 0 && r.EnvelopeMinute == 0 { + r.EnvelopeHour = DefaultEnvelopeHour + } + } + r.ChatID = chatID + r.EnvelopeEnabled = cmd.Enable + if cmd.Enable && cmd.HasTime { + r.EnvelopeHour, r.EnvelopeMinute = cmd.Hour, cmd.Minute + } + + if err := store.SetEnvelopeReminder(ctx, r); err != nil { + return "", err + } + + if !cmd.Enable { + return "🔕 Утренние конверты больше не присылаю.", nil + } + return fmt.Sprintf("✉️ Буду присылать конверты каждое утро в %02d:%02d (%s).", r.EnvelopeHour, r.EnvelopeMinute, r.Timezone), nil +} diff --git a/internal/notify/envelope_command_test.go b/internal/notify/envelope_command_test.go new file mode 100644 index 0000000..34b1b93 --- /dev/null +++ b/internal/notify/envelope_command_test.go @@ -0,0 +1,196 @@ +package notify + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/jackc/pgx/v5" + + "simpleAI/internal/budget" +) + +func TestParseEnvelopeCommand_Enable(t *testing.T) { + cases := []string{ + "присылай конверты по утрам", + "Присылай конверты по утрам!", + "шли конверты утром", + "включи утренние конверты", + } + for _, in := range cases { + cmd, ok := ParseEnvelopeCommand(in) + if !ok || !cmd.Enable { + t.Fatalf("%q: want enable, got ok=%v cmd=%+v", in, ok, cmd) + } + if cmd.HasTime { + t.Fatalf("%q: time not named, got %+v", in, cmd) + } + } +} + +func TestParseEnvelopeCommand_Disable(t *testing.T) { + cases := []string{ + "не присылай конверты", + "не шли конверты по утрам", + "выключи утренние конверты", + "отключи конверты по утрам", + } + for _, in := range cases { + cmd, ok := ParseEnvelopeCommand(in) + if !ok { + t.Fatalf("%q: not recognized", in) + } + if cmd.Enable { + t.Fatalf("%q: want disable, got %+v", in, cmd) + } + } +} + +func TestParseEnvelopeCommand_WithTime(t *testing.T) { + cmd, ok := ParseEnvelopeCommand("присылай конверты по утрам в 7:30") + if !ok || !cmd.Enable || !cmd.HasTime { + t.Fatalf("want enable with time, got ok=%v cmd=%+v", ok, cmd) + } + if cmd.Hour != 7 || cmd.Minute != 30 { + t.Fatalf("want 07:30, got %02d:%02d", cmd.Hour, cmd.Minute) + } + + cmd, ok = ParseEnvelopeCommand("присылай конверты по утрам в 9") + if !ok || cmd.Hour != 9 || cmd.Minute != 0 || !cmd.HasTime { + t.Fatalf("want 09:00, got ok=%v cmd=%+v", ok, cmd) + } +} + +func TestParseEnvelopeCommand_Unrelated(t *testing.T) { + cases := []string{ + "покажи конверты", + "купил еды на 300", + "присылай напоминания по утрам", + "", + } + for _, in := range cases { + if cmd, ok := ParseEnvelopeCommand(in); ok { + t.Fatalf("%q: must not be an envelope-schedule command, got %+v", in, cmd) + } + } +} + +type fakeEnvelopeReminderStore struct { + get *budget.Reminder + getErr error + saved *budget.Reminder +} + +func (f *fakeEnvelopeReminderStore) GetReminder(_ context.Context, _ int64) (*budget.Reminder, error) { + if f.getErr != nil { + return nil, f.getErr + } + return f.get, nil +} + +func (f *fakeEnvelopeReminderStore) SetEnvelopeReminder(_ context.Context, r budget.Reminder) error { + cp := r + f.saved = &cp + return nil +} + +// Настройки нет вовсе → дефолт 08:00 в поясе оператора. +func TestApplyEnvelopeCommand_EnableDefaults(t *testing.T) { + store := &fakeEnvelopeReminderStore{getErr: fmt.Errorf("get reminder: %w", pgx.ErrNoRows)} + cmd, _ := ParseEnvelopeCommand("присылай конверты по утрам") + + reply, err := ApplyEnvelopeCommand(context.Background(), store, 42, cmd, "Asia/Bangkok") + if err != nil { + t.Fatalf("apply: %v", err) + } + if store.saved == nil { + t.Fatal("nothing saved") + } + got := *store.saved + if got.ChatID != 42 || !got.EnvelopeEnabled || got.EnvelopeHour != DefaultEnvelopeHour || got.Timezone != "Asia/Bangkok" { + t.Fatalf("unexpected saved settings: %+v", got) + } + if !strings.Contains(reply, "08:00") { + t.Fatalf("reply must name the time: %q", reply) + } +} + +// Пояс уже сохранён — команда его не затирает. +func TestApplyEnvelopeCommand_KeepsStoredTimezone(t *testing.T) { + store := &fakeEnvelopeReminderStore{get: &budget.Reminder{ + ChatID: 42, Enabled: true, NotifyHour: 21, Timezone: "Europe/Moscow", + }} + cmd, _ := ParseEnvelopeCommand("присылай конверты по утрам в 7:30") + + if _, err := ApplyEnvelopeCommand(context.Background(), store, 42, cmd, "Asia/Bangkok"); err != nil { + t.Fatalf("apply: %v", err) + } + got := *store.saved + if got.Timezone != "Europe/Moscow" { + t.Fatalf("stored timezone overwritten: %+v", got) + } + if got.EnvelopeHour != 7 || got.EnvelopeMinute != 30 { + t.Fatalf("want 07:30, got %+v", got) + } + if !got.Enabled || got.NotifyHour != 21 { + t.Fatalf("evening reminder must survive: %+v", got) + } +} + +// Выключение не трогает время и вечернее напоминание. +func TestApplyEnvelopeCommand_Disable(t *testing.T) { + store := &fakeEnvelopeReminderStore{get: &budget.Reminder{ + ChatID: 42, Timezone: "Asia/Bangkok", + EnvelopeEnabled: true, EnvelopeHour: 7, EnvelopeMinute: 30, + }} + cmd, _ := ParseEnvelopeCommand("не присылай конверты") + + reply, err := ApplyEnvelopeCommand(context.Background(), store, 42, cmd, "Asia/Bangkok") + if err != nil { + t.Fatalf("apply: %v", err) + } + got := *store.saved + if got.EnvelopeEnabled { + t.Fatalf("want disabled, got %+v", got) + } + if got.EnvelopeHour != 7 || got.EnvelopeMinute != 30 { + t.Fatalf("time must be preserved on disable: %+v", got) + } + if strings.TrimSpace(reply) == "" { + t.Fatal("reply must not be empty") + } +} + +// Временная ошибка БД — НЕ повод считать, что настройки нет: запись дефолтов +// поверх живой строки стирает вечернее напоминание оператора. +func TestApplyEnvelopeCommand_DBErrorDoesNotOverwrite(t *testing.T) { + boom := errors.New("connection refused") + store := &fakeEnvelopeReminderStore{getErr: boom} + cmd, _ := ParseEnvelopeCommand("присылай конверты по утрам") + + reply, err := ApplyEnvelopeCommand(context.Background(), store, 42, cmd, "Asia/Bangkok") + if !errors.Is(err, boom) { + t.Fatalf("ожидалась ошибка чтения, получено err=%v reply=%q", err, reply) + } + if store.saved != nil { + t.Fatalf("при ошибке чтения ничего писать нельзя, записано: %+v", *store.saved) + } +} + +// Строки действительно нет (ErrNoRows) — первое включение обязано её создать. +func TestApplyEnvelopeCommand_NoRowsCreatesSettings(t *testing.T) { + store := &fakeEnvelopeReminderStore{getErr: fmt.Errorf("get reminder: %w", pgx.ErrNoRows)} + cmd, _ := ParseEnvelopeCommand("присылай конверты по утрам") + + if _, err := ApplyEnvelopeCommand(context.Background(), store, 42, cmd, "Asia/Bangkok"); err != nil { + t.Fatalf("apply: %v", err) + } + if store.saved == nil { + t.Fatal("первое включение должно создать настройку") + } + if got := *store.saved; !got.EnvelopeEnabled || got.EnvelopeHour != DefaultEnvelopeHour { + t.Fatalf("unexpected saved settings: %+v", got) + } +} diff --git a/internal/notify/envelope_morning_test.go b/internal/notify/envelope_morning_test.go new file mode 100644 index 0000000..c56de4e --- /dev/null +++ b/internal/notify/envelope_morning_test.go @@ -0,0 +1,127 @@ +package notify + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "simpleAI/internal/budget" +) + +type stubEnvelopes struct { + out string + err error + calls int + lastTZ string +} + +func (s *stubEnvelopes) MorningEnvelopes(_ context.Context, _ int64, loc *time.Location) (string, error) { + s.calls++ + s.lastTZ = loc.String() + return s.out, s.err +} + +func envelopeWorker(r budget.Reminder, src EnvelopeSource) (*ReminderWorker, *captureSender) { + store := &stubReminderStore{reminders: []budget.Reminder{r}} + sender := &captureSender{} + w := NewReminderWorker(store, sender, nil, discardLogger()).WithEnvelopes(src) + return w, sender +} + +// Бангкокское утро наступает в 01:00 UTC — воркер обязан считать час в +// часовом поясе оператора, а не в UTC. +func TestCheck_EnvelopeTimezoneBoundary_Bangkok(t *testing.T) { + r := budget.Reminder{ + ChatID: 1, Enabled: false, + EnvelopeEnabled: true, EnvelopeHour: 8, EnvelopeMinute: 0, + Timezone: "Asia/Bangkok", + } + src := &stubEnvelopes{out: "🍚 Еда: 5000 ฿"} + w, sender := envelopeWorker(r, src) + + // 01:00 UTC = 08:00 в Бангкоке → пуш есть. + w.check(context.Background(), time.Date(2026, 8, 24, 1, 0, 0, 0, time.UTC)) + if len(sender.sent) != 1 { + t.Fatalf("want 1 push at 01:00 UTC (08:00 Bangkok), got %d", len(sender.sent)) + } + if !strings.Contains(sender.sent[0], "Еда") { + t.Fatalf("envelope body missing: %q", sender.sent[0]) + } + if src.lastTZ != "Asia/Bangkok" { + t.Fatalf("want source called in Asia/Bangkok, got %q", src.lastTZ) + } + + // 08:00 UTC = 15:00 в Бангкоке → пуша быть не должно. + sender.sent = nil + w.check(context.Background(), time.Date(2026, 8, 24, 8, 0, 0, 0, time.UTC)) + if len(sender.sent) != 0 { + t.Fatalf("want no push at 08:00 UTC (15:00 Bangkok), got %d", len(sender.sent)) + } +} + +// Нет активного конверта (пустое тело) → пуша нет. +func TestCheck_EnvelopeNoActiveEnvelope_NoPush(t *testing.T) { + r := budget.Reminder{ + ChatID: 1, EnvelopeEnabled: true, EnvelopeHour: 8, Timezone: "Asia/Bangkok", + } + src := &stubEnvelopes{out: ""} + w, sender := envelopeWorker(r, src) + + w.check(context.Background(), time.Date(2026, 8, 24, 1, 0, 0, 0, time.UTC)) + if src.calls != 1 { + t.Fatalf("want source consulted once, got %d", src.calls) + } + if len(sender.sent) != 0 { + t.Fatalf("want no push without active envelope, got %d: %q", len(sender.sent), sender.sent) + } +} + +// Ошибка источника — не повод слать пустой пуш. +func TestCheck_EnvelopeSourceError_NoPush(t *testing.T) { + r := budget.Reminder{ChatID: 1, EnvelopeEnabled: true, EnvelopeHour: 8, Timezone: "Asia/Bangkok"} + w, sender := envelopeWorker(r, &stubEnvelopes{err: errors.New("boom")}) + + w.check(context.Background(), time.Date(2026, 8, 24, 1, 0, 0, 0, time.UTC)) + if len(sender.sent) != 0 { + t.Fatalf("want no push on source error, got %d", len(sender.sent)) + } +} + +// Выключенная утренняя рассылка не шлётся, даже если вечернее напоминание включено. +func TestCheck_EnvelopeDisabled_NoPush(t *testing.T) { + r := budget.Reminder{ + ChatID: 1, Enabled: true, NotifyHour: 21, Timezone: "Asia/Bangkok", + EnvelopeEnabled: false, EnvelopeHour: 8, + } + src := &stubEnvelopes{out: "🍚 Еда: 5000 ฿"} + w, sender := envelopeWorker(r, src) + + w.check(context.Background(), time.Date(2026, 8, 24, 1, 0, 0, 0, time.UTC)) + if src.calls != 0 { + t.Fatalf("disabled morning push must not consult source, got %d calls", src.calls) + } + if len(sender.sent) != 0 { + t.Fatalf("want no push when morning push disabled, got %d", len(sender.sent)) + } +} + +// Вечернее напоминание выключено (enabled=false) — оно не должно уходить, +// даже когда строка попала в выборку ради утренних конвертов. +func TestCheck_ReminderDisabled_OnlyEnvelopePush(t *testing.T) { + r := budget.Reminder{ + ChatID: 1, Enabled: false, NotifyHour: 8, NotifyMinute: 0, + EnvelopeEnabled: true, EnvelopeHour: 8, EnvelopeMinute: 0, + Timezone: "UTC", + } + w, sender := envelopeWorker(r, &stubEnvelopes{out: "🍚 Еда: 5000 ฿"}) + + w.check(context.Background(), time.Date(2026, 8, 24, 8, 0, 0, 0, time.UTC)) + if len(sender.sent) != 1 { + t.Fatalf("want exactly 1 message (envelope only), got %d: %q", len(sender.sent), sender.sent) + } + if strings.Contains(sender.sent[0], "Не забудь") { + t.Fatalf("disabled evening reminder leaked: %q", sender.sent[0]) + } +} diff --git a/internal/notify/reminder_worker.go b/internal/notify/reminder_worker.go index 50d290c..03b7812 100644 --- a/internal/notify/reminder_worker.go +++ b/internal/notify/reminder_worker.go @@ -3,6 +3,7 @@ package notify import ( "context" "log/slog" + "strings" "time" "simpleAI/internal/budget" @@ -25,12 +26,30 @@ type DigestSource interface { YesterdayDigest(ctx context.Context, chatID int64, loc *time.Location) (string, error) } +// EnvelopeSource строит утренний пуш с конвертами и дневным лимитом. +// Реализуется на стороне скиллов; notify знает только контракт. +// Пустая строка = активного конверта нет, слать нечего. +type EnvelopeSource interface { + MorningEnvelopes(ctx context.Context, chatID int64, loc *time.Location) (string, error) +} + // ReminderWorker каждую минуту проверяет, кому нужно отправить напоминание. +// Один воркер обслуживает оба расписания чата: вечернее напоминание и +// утренний пуш с конвертами — у них общий пояс и общая строка budget_reminder. type ReminderWorker struct { - store ReminderStore - sender ReminderSender - digest DigestSource - logger *slog.Logger + store ReminderStore + sender ReminderSender + digest DigestSource + envelopes EnvelopeSource + logger *slog.Logger +} + +// WithEnvelopes подключает источник утренних конвертов. Без него утренний +// пуш не отправляется вовсе — это делает зависимость опциональной для +// вызывающих, которым нужны только напоминания. +func (w *ReminderWorker) WithEnvelopes(src EnvelopeSource) *ReminderWorker { + w.envelopes = src + return w } // NewReminderWorker создаёт воркер напоминаний. @@ -67,7 +86,8 @@ func (w *ReminderWorker) check(ctx context.Context, now time.Time) { loc = time.UTC } local := now.In(loc) - if local.Hour() == r.NotifyHour && local.Minute() == r.NotifyMinute { + w.checkEnvelope(ctx, r, local, loc) + if r.Enabled && local.Hour() == r.NotifyHour && local.Minute() == r.NotifyMinute { text := "👋 Привет! Не забудь внести сегодняшние покупки и траты." // Дайджест за вчера — non-fatal: ошибка/пусто не блокирует напоминание. if w.digest != nil { @@ -84,3 +104,29 @@ func (w *ReminderWorker) check(ctx context.Context, now time.Time) { } } } + +// checkEnvelope отправляет утренний пуш с конвертами, если для чата он включён +// и время совпало. Тело берётся у EnvelopeSource: пусто (активного конверта +// нет) или ошибка — пуша не будет, пустое сообщение оператору бесполезно. +func (w *ReminderWorker) checkEnvelope(ctx context.Context, r budget.Reminder, local time.Time, loc *time.Location) { + if w.envelopes == nil || !r.EnvelopeEnabled { + return + } + if local.Hour() != r.EnvelopeHour || local.Minute() != r.EnvelopeMinute { + return + } + + body, err := w.envelopes.MorningEnvelopes(ctx, r.ChatID, loc) + if err != nil { + w.logger.Warn("reminder worker: envelopes failed", "chat_id", r.ChatID, "err", err) + return + } + if strings.TrimSpace(body) == "" { + w.logger.Info("reminder worker: no active envelope, skip morning push", "chat_id", r.ChatID) + return + } + + if err := w.sender.SendToChatID(ctx, r.ChatID, body); err != nil { + w.logger.Error("reminder worker: envelope push failed", "chat_id", r.ChatID, "err", err) + } +} diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go index 323d4d2..32b81a0 100644 --- a/internal/notify/telegram.go +++ b/internal/notify/telegram.go @@ -9,70 +9,77 @@ import ( "net/http" "strings" "time" + + botformat "simpleAI/internal/bot/format" ) type Telegram struct { token string chatID string client *http.Client + // baseURL — корень API. Вынесен полем только ради тестов: отправку пуша + // иначе не проверить, а именно она везёт раскладку конвертов. + baseURL string } func NewTelegram(token, chatID string) *Telegram { return &Telegram{ - token: strings.TrimSpace(token), - chatID: strings.TrimSpace(chatID), - client: &http.Client{Timeout: 10 * time.Second}, + token: strings.TrimSpace(token), + chatID: strings.TrimSpace(chatID), + client: &http.Client{Timeout: 10 * time.Second}, + baseURL: "https://api.telegram.org", } } +// Утренний пуш идёт мимо адаптера Telegram, своим HTTP-клиентом. Разметка +// здесь обязана быть той же: раскладка конвертов в пуше — тот же текст с +// моноблоком, и без parse_mode он развалился бы ровно там, где в ответе на +// команду он цел. func (t *Telegram) SendToChatID(ctx context.Context, chatID int64, text string) error { if t.token == "" { return fmt.Errorf("telegram token is not configured") } - payload := map[string]any{ - "chat_id": chatID, - "text": text, - } - body, err := json.Marshal(payload) - if err != nil { - return err - } - endpoint := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.token) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return err + return t.deliver(ctx, chatID, text) +} + +func (t *Telegram) Send(ctx context.Context, text string) error { + if t.token == "" || t.chatID == "" { + return fmt.Errorf("telegram token/chat_id is not configured") } - req.Header.Set("Content-Type", "application/json") + return t.deliver(ctx, t.chatID, text) +} - resp, err := t.client.Do(req) - if err != nil { +// deliver: размеченные куски, при неудаче — тот же текст простым. Фоллбэк +// срабатывает, только если не ушло НИЧЕГО, иначе доставленные куски +// продублировались бы в чате. +func (t *Telegram) deliver(ctx context.Context, chatID any, text string) error { + sent, err := t.postChunks(ctx, chatID, botformat.MessagesHTML(text), "HTML") + if err == nil || sent > 0 { return err } - defer func() { - if err := resp.Body.Close(); err != nil { - _ = err - } - }() + _, err = t.postChunks(ctx, chatID, botformat.MessagesPlain(text), "") + return err +} - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("telegram send failed: status=%d", resp.StatusCode) +func (t *Telegram) postChunks(ctx context.Context, chatID any, chunks []string, parseMode string) (int, error) { + for i, chunk := range chunks { + payload := map[string]any{"chat_id": chatID, "text": chunk} + if parseMode != "" { + payload["parse_mode"] = parseMode + } + if err := t.post(ctx, payload); err != nil { + return i, err + } } - return nil + return len(chunks), nil } -func (t *Telegram) Send(ctx context.Context, text string) error { - if t.token == "" || t.chatID == "" { - return fmt.Errorf("telegram token/chat_id is not configured") - } - payload := map[string]string{ - "chat_id": t.chatID, - "text": text, - } +func (t *Telegram) post(ctx context.Context, payload map[string]any) error { body, err := json.Marshal(payload) if err != nil { return err } - endpoint := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.token) + endpoint := fmt.Sprintf("%s/bot%s/sendMessage", t.baseURL, t.token) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return err diff --git a/internal/notify/telegram_test.go b/internal/notify/telegram_test.go new file mode 100644 index 0000000..763221c --- /dev/null +++ b/internal/notify/telegram_test.go @@ -0,0 +1,150 @@ +package notify + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// Утренний пуш идёт своим HTTP-клиентом, мимо адаптера Telegram. Без этих +// тестов разметка чинилась бы только в одном из двух мест отправки — и +// раскладка конвертов разваливалась бы ровно в пуше. + +type fakeAPI struct { + mu sync.Mutex + calls []map[string]any + failOn func(map[string]any) bool + srv *httptest.Server +} + +func newFakeAPI(t *testing.T) *fakeAPI { + t.Helper() + f := &fakeAPI{} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Errorf("decode: %v", err) + } + f.mu.Lock() + f.calls = append(f.calls, payload) + fail := f.failOn != nil && f.failOn(payload) + f.mu.Unlock() + if fail { + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + t.Cleanup(f.srv.Close) + return f +} + +func newTestTelegram(t *testing.T, f *fakeAPI) *Telegram { + t.Helper() + tg := NewTelegram("TOKEN", "42") + tg.baseURL = f.srv.URL + tg.client = f.srv.Client() + return tg +} + +func (f *fakeAPI) sent() []map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + return append([]map[string]any(nil), f.calls...) +} + +const pushLayout = "01.09 — 14.09 · 14 дней\n" + + "**Что осталось**\n" + + "```\n" + + "Аренда 15.09 12 000\n" + + "Еда 8 000\n" + + "```\n" + + "**На день: 1 200 ฿**" + +func TestSendToChatID_UsesHTML(t *testing.T) { + f := newFakeAPI(t) + tg := newTestTelegram(t, f) + + if err := tg.SendToChatID(context.Background(), 7, pushLayout); err != nil { + t.Fatalf("send: %v", err) + } + calls := f.sent() + if len(calls) != 1 { + t.Fatalf("ожидался один вызов, получено %d", len(calls)) + } + if calls[0]["parse_mode"] != "HTML" { + t.Errorf("пуш ушёл без parse_mode=HTML: %#v", calls[0]) + } + text, _ := calls[0]["text"].(string) + if !strings.Contains(text, "Аренда 15.09 12 000\nЕда 8 000") { + t.Errorf("моноблок пуша не стал pre:\n%s", text) + } + if !strings.Contains(text, "На день: 1 200 ฿") { + t.Errorf("заголовок пуша не жирный:\n%s", text) + } + if strings.Contains(text, "```") || strings.Contains(text, "**") { + t.Errorf("разметка уехала литералом:\n%s", text) + } +} + +// Злые символы в имени платежа не роняют пуш: экранируются той же функцией, +// что и в адаптере. +func TestSendToChatID_EscapesEvilStrings(t *testing.T) { + f := newFakeAPI(t) + tg := newTestTelegram(t, f) + + if err := tg.SendToChatID(context.Background(), 7, "Кафе <Мама & Папа> — 15.09"); err != nil { + t.Fatalf("send: %v", err) + } + text, _ := f.sent()[0]["text"].(string) + if !strings.Contains(text, "<Мама & Папа>") { + t.Fatalf("не экранировано: %q", text) + } +} + +// Длинный пуш режется по лимиту, а не роняет отправку целиком. +func TestSendToChatID_SplitsLongPush(t *testing.T) { + f := newFakeAPI(t) + tg := newTestTelegram(t, f) + + long := strings.Repeat("Категория очень длинная 1 234\n", 400) + if err := tg.SendToChatID(context.Background(), 7, long); err != nil { + t.Fatalf("send: %v", err) + } + calls := f.sent() + if len(calls) < 2 { + t.Fatalf("длинный пуш ушёл %d куском(ами)", len(calls)) + } + for i, c := range calls { + text, _ := c["text"].(string) + if n := len([]rune(text)); n > 4096 { + t.Errorf("кусок %d длиной %d — Telegram вернёт 400", i, n) + } + } +} + +// 400 на разметке не оставляет пользователя без утреннего пуша: тот же текст +// уходит простым. +func TestSendToChatID_FallsBackToPlainOn400(t *testing.T) { + f := newFakeAPI(t) + f.failOn = func(p map[string]any) bool { return p["parse_mode"] == "HTML" } + tg := newTestTelegram(t, f) + + if err := tg.SendToChatID(context.Background(), 7, pushLayout); err != nil { + t.Fatalf("фоллбэк не сработал: %v", err) + } + calls := f.sent() + last := calls[len(calls)-1] + if _, has := last["parse_mode"]; has { + t.Errorf("фоллбэк ушёл с parse_mode: %#v", last) + } + text, _ := last["text"].(string) + if !strings.Contains(text, "Аренда") { + t.Errorf("фоллбэк потерял содержимое: %q", text) + } +} diff --git a/internal/skills/budget/envelope.go b/internal/skills/budget/envelope.go index c7aef1f..7103dcb 100644 --- a/internal/skills/budget/envelope.go +++ b/internal/skills/budget/envelope.go @@ -3,15 +3,31 @@ package budgetskill import ( "context" "fmt" + "log/slog" + "strings" "time" "simpleAI/internal/agent" "simpleAI/internal/budget" + "simpleAI/internal/skills/safetospend" ) -// startEnvelope сохраняет «живой конверт» (ADR-007 H3): приход + горизонт. -// Остаток не хранится — считается позже из фактических трат (safe_to_spend). -// Границы горизонта — единый budget.ResolveHorizon (без дублирования резолвера). +// envelopeForecastMonths — окно истории, из которого считаются лимиты долей. +// Совпадает с окном прогноза в safe_to_spend: лимит и допуск к нему (глубина +// истории) обязаны меряться по одной и той же выборке. +const envelopeForecastMonths = 3 + +// startEnvelope заводит «живой конверт» (ADR-007 H3) СРАЗУ с раскладкой прихода +// по категорийным долям (ADR-008). +// +// Порядок продиктован ADR-008 §3: сначала снимаются обязательства, делится +// только FreeAfterObligations. Раскладать сырой приход нельзя — доли начали бы +// конкурировать с уже известными регулярными платежами и суммарно обещали бы +// больше денег, чем есть. +// +// Конверт и его доли пишутся ОДНОЙ транзакцией: конверт без раскладки — это +// состояние, в котором трате некуда падать (budget.ResolveShare вернёт nil), а +// на вопрос «сколько осталось в конвертах» отвечать нечем. func (s *BudgetSkill) startEnvelope(ctx context.Context, req budgetInput) (string, error) { chatID, ok := ctx.Value(agent.ChatIDKey{}).(int64) if !ok || chatID == 0 { @@ -20,15 +36,255 @@ func (s *BudgetSkill) startEnvelope(ctx context.Context, req budgetInput) (strin if req.Amount <= 0 { return "", fmt.Errorf("amount must be positive") } - currency := req.Currency + currency := strings.ToUpper(strings.TrimSpace(req.Currency)) if currency == "" { currency = "RUB" } + rates, err := s.store.GetExchangeRates(ctx) + if err != nil || rates["THB"] == 0 { + slog.Default().ErrorContext(ctx, "start_envelope: rates", "err", err) + return "Не могу разложить приход — обнови курс валют командой /rates.", nil + } + incomeTHB, ok := budget.ToTHB(req.Amount, currency, rates) + if !ok { + return fmt.Sprintf("Не знаю курс валюты %s — обнови /rates.", currency), nil + } + h := budget.ResolveHorizon(req.Period, time.Now(), budget.DefaultHorizonDays) - if _, err := s.store.CreateEnvelope(ctx, chatID, req.Amount, currency, h.From, h.To); err != nil { - return "", err + + // Обязательства — за весь горизонт конверта (та же семантика, что в + // safe_to_spend: recurring.next_date / debt.due_date <= конец периода). + snap, err := s.store.GetPeriodSnapshot(ctx, chatID, h.From, h.To, rates) + if err != nil { + slog.Default().ErrorContext(ctx, "start_envelope: period snapshot", "err", err, "chat_id", chatID) + return "Временная ошибка при сборе финансового снимка — попробуй позже.", nil + } + plannedTHB, _, err := s.store.PlannedExpensesTHB(ctx, chatID, rates) + if err != nil { + slog.Default().WarnContext(ctx, "start_envelope: planned (continuing with 0)", "err", err) + plannedTHB = 0 + } + + forecast, err := s.store.GetForecastData(ctx, envelopeForecastMonths, rates) + if err != nil { + slog.Default().ErrorContext(ctx, "start_envelope: forecast", "err", err) + return "Не удалось поднять историю трат — без неё раскладка была бы выдумана. Попробуй позже.", nil + } + // Глубина истории — из БД, а не из длины прогноза: allocateShares по пустой + // карте не назначит НИ ОДНОГО лимита и уведёт весь приход в накопления. + history, err := s.store.CategoryHistoryMonths(ctx, envelopeForecastMonths) + if err != nil { + slog.Default().ErrorContext(ctx, "start_envelope: history depth", "err", err) + return "Не удалось оценить глубину истории трат — попробуй позже.", nil + } + overrides := s.shareOverrides(ctx, chatID, rates) + // Регулярные платежи становятся видимыми конвертами (simpleAI-faeq.11). + // Ошибка чтения НЕ проглатывается: без них раскладка разделит между едой и + // накоплениями деньги, которые уже обещаны аренде и кредиту. + recurring, err := s.store.ListRecurring(ctx, chatID) + if err != nil { + slog.Default().ErrorContext(ctx, "start_envelope: recurring", "err", err, "chat_id", chatID) + return "Не удалось поднять регулярные платежи — без них раскладка обещала бы деньги, которые уже расписаны. Попробуй позже.", nil + } + + plan := safetospend.PlanEnvelope(safetospend.EnvelopePlanInput{ + IncomeTHB: incomeTHB, + Snapshot: snap, + PlannedTHB: plannedTHB, + Forecast: forecast, + Rates: rates, + Days: h.Days(), + Overrides: overrides, + History: history, + Recurring: recurring, + From: h.From, + }) + s.attachCategoryIDs(ctx, plan.Shares) + + // Перенос накопленного с прошлого конверта — ДО записи нового: доли пишутся + // одной транзакцией с конвертом, и carried_in должен быть уже в них. + now := time.Now() + carried, err := s.carryFromPrevious(ctx, chatID, rates, now, plan.Shares) + if err != nil { + slog.Default().ErrorContext(ctx, "start_envelope: carry over", "err", err, "chat_id", chatID) + return "Не удалось поднять накопления с прошлого конверта — новый конверт не заведён, чтобы они не потерялись. Попробуй ещё раз.", nil + } + plan.Shares = carried + + envID, err := s.store.CreateEnvelopeWithShares(ctx, chatID, req.Amount, currency, h.From, h.To, plan.Shares, now) + if err != nil { + slog.Default().ErrorContext(ctx, "start_envelope: create envelope with shares", "err", err, "chat_id", chatID) + return "Не удалось сохранить конверт — попробуй ещё раз.", nil + } + + slog.Default().InfoContext(ctx, "start_envelope", + "chat_id", chatID, "envelope_id", envID, "amount", req.Amount, "currency", currency, + "free_after_obl_thb", plan.Result.FreeAfterObligations, "shares", len(plan.Shares), + "warnings", len(plan.Warnings), "display", envelopeDisplay(req, rates).Code) + + // Строки «вне конвертов» больше нет: она показывала 0 ฿ при аренде 18 000 и + // уборке 2 500 за период (simpleAI-faeq.10, баг 4) — потому что считала + // ФАКТ прошедших трат, а оператор читал её как «что ещё предстоит». Теперь + // каждый предстоящий платёж стоит своей строкой с датой, и объяснять + // расхождение отдельной сводкой больше нечем. + + return safetospend.FormatEnvelopePlan(safetospend.EnvelopeReply{ + Plan: plan, + RubPerTHB: rates["THB"], + Display: envelopeDisplay(req, rates), + Period: h.Label, + From: h.From, + To: h.To, + IncomeAmount: req.Amount, + IncomeCurrency: currency, + }), nil +} + +// envelopeDisplay — валюта, которой печатать конверты: то, что попросил +// оператор, иначе дефолт THB (safetospend.NewDisplay). Одна точка на весь +// budget-скилл, чтобы раскладка, пересчёт лимита и предупреждение о пробитом +// конверте не разъехались по валютам. +func envelopeDisplay(req budgetInput, rates map[string]float64) safetospend.Display { + return safetospend.NewDisplay(displayCode(req), rates["THB"]) +} + +// displayCode — код валюты показа из входа LLM, со страховкой разбором текста: +// модель кладёт просьбу «в рублях» то в display_currency, то в description, то +// не кладёт никуда. Пусто → NewDisplay сам возьмёт дефолт (THB). +func displayCode(req budgetInput) string { + if c := strings.TrimSpace(req.DisplayCurrency); c != "" { + return c + } + return safetospend.ParseDisplayCurrency(req.Description) +} + +// carryFromPrevious переносит накопления прошлого активного конверта в новую +// раскладку (ADR-008 §9). Остаток считается computeShareRemaining внутри +// safetospend.CarryOver — второй формулы остатка в проекте нет. +// +// Ошибка здесь НЕ проглатывается, в отличие от override'ов и категорий: без +// раскладки лимиты просто станут авто-лимитами, а без переноса накопленные +// деньги молча исчезнут вместе со старым конвертом. Дешевле не завести конверт. +func (s *BudgetSkill) carryFromPrevious( + ctx context.Context, + chatID int64, + rates map[string]float64, + now time.Time, + next []budget.EnvelopeShare, +) ([]budget.EnvelopeShare, error) { + prev, ok, err := s.store.GetActiveEnvelope(ctx, chatID) + if err != nil { + return nil, fmt.Errorf("прошлый конверт: %w", err) + } + if !ok { + // Первый приход: переносить нечего, но carried_in всё равно обнуляем + // явно — этим занимается CarryOver с пустой прошлой раскладкой. + return safetospend.CarryOver(safetospend.CarryInput{Rates: rates, NextShares: next}), nil + } + prevShares, err := s.store.ListShares(ctx, chatID, prev.ID) + if err != nil { + return nil, fmt.Errorf("доли прошлого конверта: %w", err) + } + if len(prevShares) == 0 { + return safetospend.CarryOver(safetospend.CarryInput{Rates: rates, NextShares: next}), nil + } + + // Факт считается за тот период, которым конверт СЕЙЧАС и закроется: + // period_end := now − 1 день, обрезанный сверху своим period_end и снизу + // period_start (ADR-008 §10). Иначе перенос учёл бы траты дня переключения, + // которые по факту достанутся уже новому конверту. + to := now.AddDate(0, 0, -1) + if to.After(prev.PeriodEnd) { + to = prev.PeriodEnd + } + var spent []budget.CategorySpentRow + superseded := to.Before(prev.PeriodStart) + if !superseded { + spent, err = s.store.SpentByCategoryExcludingRecurring(ctx, prev.PeriodStart, to) + if err != nil { + return nil, fmt.Errorf("факт прошлого конверта: %w", err) + } + } + // to < period_start — конверт заведён и закрыт в один день: он не прожил ни + // дня и вытесняется повторной раскладкой. Переносить его накопления целиком + // нельзя — они профинансированы тем же приходом (simpleAI-faeq.10, баг 1). + + return safetospend.CarryOver(safetospend.CarryInput{ + PrevShares: prevShares, + PrevSpent: spent, + Rates: rates, + NextShares: next, + PrevSuperseded: superseded, + }), nil +} + +// shareOverrides — ручные лимиты долей, приведённые к THB (доли хранятся в THB, +// а override оператор мог задать в рублях). Ключ — уже нормализованное имя из +// стора. Ошибка чтения не валит раскладку: без override'ов она просто вернётся +// к авто-лимитам, а отказ отвечать хуже. +func (s *BudgetSkill) shareOverrides(ctx context.Context, chatID int64, rates map[string]float64) map[string]float64 { + list, err := s.store.ListOverrides(ctx, chatID) + if err != nil { + slog.Default().WarnContext(ctx, "start_envelope: overrides (continuing without)", "err", err) + return nil + } + return overridesToTHB(list, rates) +} + +// overridesToTHB — единственное место, где ручной лимит переводится в THB. +// +// Вынесено чистой функцией нарочно: «лимит в рублях и лимит в батах при одном +// курсе дают одну и ту же долю» — утверждение про конвертацию, и проверять его +// надо там, где она происходит, а не через живой стор. Лимит хранится как +// сказан (сумма + код валюты), курс применяется ровно один раз — здесь; если +// бы конвертация была ещё и на записи, курс задвоился бы. +func overridesToTHB(list []budget.EnvelopeOverride, rates map[string]float64) map[string]float64 { + out := make(map[string]float64, len(list)) + for _, o := range list { + thb, ok := budget.ToTHB(o.Amount, o.Currency, rates) + if !ok { + slog.Default().Warn("start_envelope: override без курса — пропущен", + "share", o.ShareName, "currency", o.Currency) + continue + } + out[o.ShareName] = thb + } + return out +} + +// attachCategoryIDs проставляет долям category_id по справочнику категорий. +// Аллокатор знает только имена (прогноз name-keyed), но матчинг траты к доле +// идёт СНАЧАЛА по id (budget.ResolveShare): без id доля не поймает транзакцию, +// у которой имя категории отличается регистром от прогнозного. +// Best-effort: категории не прочитались — остаётся матчинг по имени. +func (s *BudgetSkill) attachCategoryIDs(ctx context.Context, shares []budget.EnvelopeShare) { + cats, err := s.store.ListCategories(ctx) + if err != nil { + slog.Default().WarnContext(ctx, "start_envelope: categories (матчинг только по имени)", "err", err) + return + } + byName := make(map[string]budget.Category, len(cats)) + for _, c := range cats { + if c.Type != "expense" { + continue + } + key := budget.NormalizeName(c.Name) + if _, exists := byName[key]; exists { + // Регистровые дубли («Еда» и «еда» — разные строки с разными id): + // какой из них «тот самый», неизвестно, поэтому не берём ни один — + // матчинг по имени всё равно поймает оба. + continue + } + byName[key] = c + } + for i := range shares { + for j := range shares[i].Categories { + key := budget.NormalizeName(shares[i].Categories[j].CategoryName) + if c, ok := byName[key]; ok { + id := c.ID + shares[i].Categories[j].CategoryID = &id + } + } } - return fmt.Sprintf("🧧 Конверт создан: приход %.0f %s на горизонт %s.\nСпроси «сколько свободно осталось?» в любой момент — пересчитаю по фактическим тратам.", - req.Amount, currency, h.Label), nil } diff --git a/internal/skills/budget/envelope_test.go b/internal/skills/budget/envelope_test.go new file mode 100644 index 0000000..110e2e6 --- /dev/null +++ b/internal/skills/budget/envelope_test.go @@ -0,0 +1,198 @@ +package budgetskill + +import ( + "context" + "math" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "simpleAI/internal/agent" + "simpleAI/internal/budget" +) + +// envelopeTestChatID — отдельный chat для тестов конверта, чтобы не задеть +// реальные конверты на реплике. +const envelopeTestChatID = int64(-70041) + +// newEnvelopeTestSkill поднимает скилл на write-реплике. Без +// BOTCLIENT_DATABASE_URL_RW тест пропускается: проверять запись без записи +// нечем, а «зелёный» пропуск — пустой тест. +func newEnvelopeTestSkill(t *testing.T) (*BudgetSkill, *pgxpool.Pool, context.Context) { + t.Helper() + url := os.Getenv("BOTCLIENT_DATABASE_URL_RW") + if url == "" { + t.Skip("BOTCLIENT_DATABASE_URL_RW не задан — write-доступ к реплике недоступен") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(pool.Close) + + cleanup := func() { + if _, err := pool.Exec(ctx, `DELETE FROM budget_envelope WHERE chat_id = $1`, envelopeTestChatID); err != nil { + t.Logf("cleanup budget_envelope: %v", err) + } + if _, err := pool.Exec(ctx, `DELETE FROM budget_envelope_limit_override WHERE chat_id = $1`, envelopeTestChatID); err != nil { + t.Logf("cleanup override: %v", err) + } + } + cleanup() + t.Cleanup(cleanup) + + skill := NewBudgetSkill(budget.NewStore(pool)) + return skill, pool, context.WithValue(ctx, agent.ChatIDKey{}, envelopeTestChatID) +} + +// TestStartEnvelope_WritesSharesAndReply — гейт задачи 4/8. +// +// Главное утверждение — ПО ФАКТУ В БАЗЕ: после start_envelope у конверта есть +// доли и их сумма равна FreeAfterObligations. Оно и есть мутационная проверка: +// вырезав раскладку из startEnvelope, получим конверт без строк в +// budget_envelope_share, и тест покраснеет на запросе к БД, а не на тексте +// ответа (текст можно оставить прежним и мутацию не заметить). +func TestStartEnvelope_WritesSharesAndReply(t *testing.T) { + skill, pool, ctx := newEnvelopeTestSkill(t) + + reply, err := skill.Run(ctx, `{"action":"start_envelope","amount":127000,"currency":"RUB"}`) + if err != nil { + t.Fatalf("start_envelope: %v", err) + } + t.Logf("ответ бота:\n%s", reply) + + env, ok, err := skill.store.GetActiveEnvelope(ctx, envelopeTestChatID) + if err != nil { + t.Fatalf("GetActiveEnvelope: %v", err) + } + if !ok { + t.Fatal("конверт не создан") + } + + // --- ФАКТ В БАЗЕ: доли записаны --- + shares, err := skill.store.ListShares(ctx, envelopeTestChatID, env.ID) + if err != nil { + t.Fatalf("ListShares: %v", err) + } + if len(shares) == 0 { + t.Fatal("конверт сохранён БЕЗ раскладки: в budget_envelope_share нет ни одной доли") + } + + // Инвариант ADR-008 §4: Σ allocated = FreeAfterObligations. Свободные деньги + // считаются здесь заново из стора, а не парсятся из ответа: иначе тест + // сверял бы текст сам с собой. + rates, err := skill.store.GetExchangeRates(ctx) + if err != nil { + t.Fatalf("rates: %v", err) + } + incomeTHB, okRate := budget.ToTHB(127000, "RUB", rates) + if !okRate { + t.Fatal("нет курса RUB→THB") + } + snap, err := skill.store.GetPeriodSnapshot(ctx, envelopeTestChatID, env.PeriodStart, env.PeriodEnd, rates) + if err != nil { + t.Fatalf("GetPeriodSnapshot: %v", err) + } + plannedTHB, _, err := skill.store.PlannedExpensesTHB(ctx, envelopeTestChatID, rates) + if err != nil { + t.Fatalf("PlannedExpensesTHB: %v", err) + } + wantFree := incomeTHB - snap.UpcomingRecurring - snap.ActiveDebtDue - plannedTHB + + var sum float64 + var hasFallback, hasSavings bool + for _, sh := range shares { + sum += sh.Allocated + switch strings.ToLower(sh.Name) { + case budget.FallbackShareName: + hasFallback = true + } + if sh.Kind == budget.ShareKindSave { + hasSavings = true + } + } + if math.Abs(sum-wantFree) > 0.01 { + t.Errorf("Σ allocated = %.2f, а свободных %.2f — раскладка не сходится (ADR-008 §4)", sum, wantFree) + } + if !hasFallback { + t.Errorf("нет доли-приёмника «%s»: трате в неизвестной категории некуда падать", budget.FallbackShareName) + } + if !hasSavings { + t.Error("нет доли с kind=save: непокрытый остаток потерян") + } + + // --- ОТВЕТ: строка по каждой доле + обязательные строки --- + for _, sh := range shares { + if sh.Kind == budget.ShareKindSave { + continue // накопления показываются строкой «Свободно» + } + if !strings.Contains(strings.ToLower(reply), strings.ToLower(sh.Name)) { + t.Errorf("в ответе нет строки по доле %q:\n%s", sh.Name, reply) + } + } + for _, want := range []string{"Приход", "Обязательства", "К раскладке", "Свободно", "Вне конвертов"} { + if !strings.Contains(reply, want) { + t.Errorf("в ответе нет обязательной строки %q:\n%s", want, reply) + } + } + + // Повторный приход деактивирует прошлый конверт, а не плодит второй активный. + if _, err := skill.Run(ctx, `{"action":"start_envelope","amount":50000,"currency":"RUB"}`); err != nil { + t.Fatalf("второй start_envelope: %v", err) + } + var activeCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM budget_envelope WHERE chat_id = $1 AND active`, envelopeTestChatID).Scan(&activeCount); err != nil { + t.Fatalf("count active: %v", err) + } + if activeCount != 1 { + t.Errorf("активных конвертов %d, ожидали 1", activeCount) + } +} + +// TestStartEnvelope_WarningsInReply — warnings аллокатора доезжают до ответа. +// Триггер детерминированный: ручной лимит на «накопления» запрещён всегда +// (они — непокрытый остаток, а не лимит), поэтому warning не зависит от того, +// какая история трат лежит на реплике. +func TestStartEnvelope_WarningsInReply(t *testing.T) { + skill, _, ctx := newEnvelopeTestSkill(t) + + if err := skill.store.SetOverride(ctx, envelopeTestChatID, "накопления", 5000, "RUB"); err != nil { + t.Fatalf("SetOverride: %v", err) + } + reply, err := skill.Run(ctx, `{"action":"start_envelope","amount":127000,"currency":"RUB"}`) + if err != nil { + t.Fatalf("start_envelope: %v", err) + } + if !strings.Contains(reply, "⚠️") || !strings.Contains(reply, "накопления") { + t.Errorf("warning аллокатора не доехал до ответа:\n%s", reply) + } +} + +// TestCreateEnvelopeWithShares_Atomic — конверт и раскладка пишутся одной +// транзакцией. Проверяется по факту отката: доли с дублем имени отбиваются +// UNIQUE(envelope_id,name), и после ошибки конверта в базе быть НЕ должно. +// Без общей транзакции конверт остался бы активным и пустым. +func TestCreateEnvelopeWithShares_Atomic(t *testing.T) { + skill, pool, ctx := newEnvelopeTestSkill(t) + + from := time.Now() + dup := []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 100, Source: budget.ShareSourceAuto, Position: 0}, + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 200, Source: budget.ShareSourceAuto, Position: 1}, + } + if _, err := skill.store.CreateEnvelopeWithShares(ctx, envelopeTestChatID, 1000, "RUB", from, from.AddDate(0, 0, 14), dup, time.Now()); err == nil { + t.Fatal("дубль имени доли должен отбиваться UNIQUE(envelope_id,name)") + } + + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM budget_envelope WHERE chat_id = $1`, envelopeTestChatID).Scan(&count); err != nil { + t.Fatalf("count envelopes: %v", err) + } + if count != 0 { + t.Errorf("после провала раскладки осталось %d конвертов — запись не атомарна", count) + } +} diff --git a/internal/skills/budget/limits.go b/internal/skills/budget/limits.go new file mode 100644 index 0000000..cab7bd8 --- /dev/null +++ b/internal/skills/budget/limits.go @@ -0,0 +1,274 @@ +package budgetskill + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/google/uuid" + + "simpleAI/internal/agent" + "simpleAI/internal/budget" + "simpleAI/internal/skills/safetospend" +) + +// Ручная правка лимита конверта словами (ADR-008 §2): «на еду хватит 15000». +// +// Почему это действие budget-скилла, а не safe_to_spend: правка ПИШЕТ в базу +// (budget_envelope_limit_override + перезапись долей активного конверта), а +// safe_to_spend по ADR-002 read-only. +// +// Правка живёт МЕЖДУ приходами: она сохраняется отдельной строкой override'а и +// применяется при каждой следующей раскладке, пока её не сняли. Поэтому +// «применить к текущему конверту» и «запомнить» — два разных шага, и первый без +// второго был бы правкой на один период (а оператор просил правило). + +// setShareLimit сохраняет ручной лимит доли и пересчитывает активный конверт. +// +// Порядок именно такой: сначала запись override'а, потом пересчёт. Пересчёт +// читает override'ы из базы (общий путь с start_envelope — shareOverrides), так +// что несохранённый лимит в раскладку бы не попал; а если пересчёт упадёт, +// сохранённое правило всё равно применится при следующем приходе. +func (s *BudgetSkill) setShareLimit(ctx context.Context, req budgetInput) (string, error) { + chatID, ok := ctx.Value(agent.ChatIDKey{}).(int64) + if !ok || chatID == 0 { + return "Не удалось определить чат — попробуй ещё раз.", nil + } + name := shareLimitName(req) + if name == "" { + return "На какой конверт поставить лимит? Назови категорию — например «на еду хватит 15000».", nil + } + if req.Amount <= 0 { + return fmt.Sprintf("Сколько закладывать на «%s»? Скажи сумму — например «на %s хватит 15000».", name, name), nil + } + currency := strings.ToUpper(strings.TrimSpace(req.Currency)) + if currency == "" { + // Тот же дефолт, что у add_expense и start_envelope: НЕназванная валюта + // суммы — рубли. Это про сумму в сообщении, а не про валюту показа: + // показ по умолчанию батовый (display_currency), и путать их нельзя — + // иначе «на еду хватит 15000» без валюты молча стало бы 15000 ฿. + // + // Названную валюту («5000 бат») сюда доносит поле currency, и дальше + // она конвертируется РОВНО ОДИН раз — в shareOverrides при раскладке + // (budget.ToTHB). Здесь сумма сохраняется как сказана, вместе с кодом + // валюты: конвертировать и тут, и там значило бы задвоить курс. + currency = "RUB" + } + + if err := s.store.SetOverride(ctx, chatID, name, req.Amount, currency); err != nil { + slog.Default().ErrorContext(ctx, "set_share_limit: save override", "err", err, "chat_id", chatID, "share", name) + return "Не удалось сохранить лимит — попробуй ещё раз.", nil + } + slog.Default().InfoContext(ctx, "set_share_limit", + "chat_id", chatID, "share", name, "amount", req.Amount, "currency", currency) + + head := fmt.Sprintf("📌 Лимит на «%s» — %.0f %s. Запомнил: применю и к следующим приходам, пока не скажешь «убери лимит на %s».", + name, req.Amount, currency, name) + return head + s.replanTail(ctx, chatID, req), nil +} + +// clearShareLimit снимает ручной лимит: доля снова считается из истории трат. +// Идемпотентно — «убери лимит» на доле без override'а не ошибка, а no-op с тем +// же ответом: оператору важно состояние «лимита нет», а не факт удаления строки. +func (s *BudgetSkill) clearShareLimit(ctx context.Context, req budgetInput) (string, error) { + chatID, ok := ctx.Value(agent.ChatIDKey{}).(int64) + if !ok || chatID == 0 { + return "Не удалось определить чат — попробуй ещё раз.", nil + } + name := shareLimitName(req) + if name == "" { + return "С какого конверта снять лимит? Назови категорию — например «убери лимит на еду».", nil + } + + if err := s.store.DeleteOverride(ctx, chatID, name); err != nil { + slog.Default().ErrorContext(ctx, "clear_share_limit: delete override", "err", err, "chat_id", chatID, "share", name) + return "Не удалось снять лимит — попробуй ещё раз.", nil + } + slog.Default().InfoContext(ctx, "clear_share_limit", "chat_id", chatID, "share", name) + + head := fmt.Sprintf("🧹 Лимит на «%s» снят — снова считаю его по истории трат.", name) + return head + s.replanTail(ctx, chatID, req), nil +} + +// shareLimitName — имя доли из входа LLM. Смотрим и name, и category: доля +// называется по категории трат («еда»), и модель кладёт её то в одно поле, то в +// другое. Пустое имя — не ошибка выполнения, а переспрос (см. вызывающих). +func shareLimitName(req budgetInput) string { + if n := strings.TrimSpace(req.Name); n != "" { + return n + } + return strings.TrimSpace(req.Category) +} + +// replanTail пересчитывает активный конверт и возвращает хвост ответа. Ошибка +// пересчёта НЕ роняет ответ: сам лимит уже сохранён и применится при следующем +// приходе, а отказ отвечать вместо этого спрятал бы успешную часть работы. +func (s *BudgetSkill) replanTail(ctx context.Context, chatID int64, req budgetInput) string { + res, ok, err := s.replanActiveEnvelope(ctx, chatID, req) + switch { + case err != nil: + slog.Default().ErrorContext(ctx, "share limit: replan active envelope", "err", err, "chat_id", chatID) + return "\n\n⚠️ Текущий конверт пересчитать не удалось — правило применю к следующему приходу." + case !ok: + return "\n\nАктивного конверта сейчас нет — разложу с этим лимитом следующий приход." + default: + return "\n\n" + formatReplan(*res) + } +} + +// replanActiveEnvelope пересчитывает раскладку активного конверта и перезаписывает +// его доли. ok=false — активного конверта нет (это не ошибка). +// +// Приход и горизонт берутся из САМОГО конверта, а не из сообщения: пересчёт +// обязан делить ту же сумму на том же периоде, иначе правка одного лимита +// молча переписала бы весь конверт под сегодняшнюю дату. +func (s *BudgetSkill) replanActiveEnvelope(ctx context.Context, chatID int64, req budgetInput) (*replan, bool, error) { + env, ok, err := s.store.GetActiveEnvelope(ctx, chatID) + if err != nil { + return nil, false, fmt.Errorf("активный конверт: %w", err) + } + if !ok { + return nil, false, nil + } + + rates, err := s.store.GetExchangeRates(ctx) + if err != nil { + return nil, false, fmt.Errorf("курсы валют: %w", err) + } + if rates["THB"] == 0 { + // Без курса THB раскладку считать нечем: доли хранятся в THB. + return nil, false, fmt.Errorf("курс THB не задан — обнови /rates") + } + incomeTHB, ok := budget.ToTHB(env.IncomeAmount, env.IncomeCurrency, rates) + if !ok { + return nil, false, fmt.Errorf("нет курса валюты конверта %s", env.IncomeCurrency) + } + + h := budget.Horizon{Period: budget.Period{From: env.PeriodStart, To: env.PeriodEnd}} + snap, err := s.store.GetPeriodSnapshot(ctx, chatID, h.From, h.To, rates) + if err != nil { + return nil, false, fmt.Errorf("снимок периода: %w", err) + } + plannedTHB, _, err := s.store.PlannedExpensesTHB(ctx, chatID, rates) + if err != nil { + slog.Default().WarnContext(ctx, "replan: planned (continuing with 0)", "err", err) + plannedTHB = 0 + } + forecast, err := s.store.GetForecastData(ctx, envelopeForecastMonths, rates) + if err != nil { + return nil, false, fmt.Errorf("прогноз трат: %w", err) + } + history, err := s.store.CategoryHistoryMonths(ctx, envelopeForecastMonths) + if err != nil { + return nil, false, fmt.Errorf("глубина истории: %w", err) + } + + // Регулярные платежи — такие же конверты, как еда (simpleAI-faeq.11). Без + // них пересчёт раздал бы по гибким долям деньги, уже обещанные аренде. + recurring, err := s.store.ListRecurring(ctx, chatID) + if err != nil { + return nil, false, fmt.Errorf("регулярные платежи: %w", err) + } + + plan := safetospend.PlanEnvelope(safetospend.EnvelopePlanInput{ + IncomeTHB: incomeTHB, + Snapshot: snap, + PlannedTHB: plannedTHB, + Forecast: forecast, + Rates: rates, + Days: h.Days(), + Overrides: s.shareOverrides(ctx, chatID, rates), + History: history, + Recurring: recurring, + From: h.From, + }) + s.attachCategoryIDs(ctx, plan.Shares) + plan.Shares, err = s.keepCarriedIn(ctx, chatID, env.ID, plan.Shares) + if err != nil { + return nil, false, err + } + + if err := s.store.ReplaceShares(ctx, chatID, env.ID, plan.Shares); err != nil { + return nil, false, fmt.Errorf("перезапись долей: %w", err) + } + slog.Default().InfoContext(ctx, "share limit: envelope replanned", + "chat_id", chatID, "envelope_id", env.ID, "shares", len(plan.Shares), + "free_after_obl_thb", plan.Result.FreeAfterObligations) + return &replan{plan: plan, display: envelopeDisplay(req, rates)}, true, nil +} + +// keepCarriedIn сохраняет уже перенесённое (carried_in) в пересчитанной +// раскладке. Перенос с прошлого конверта — не результат раскладки, а факт +// прошлого периода (ADR-008 §9): пересчёт лимита его не считает заново и не +// имеет права обнулить. +// +// Кладёт его тот же safetospend.ApplyCarry, что и при заведении нового конверта. +// Своей ветки «расставить carried_in по именам» здесь быть не может: доля- +// носитель переноса («отпуск», выпавший из авто-раскладки) в plan.Shares не +// приходит — PlanEnvelope лишних save-долей не выдаёт, — и ReplaceShares молча +// вынес бы её вместе с деньгами. ApplyCarry такую долю воссоздаёт. +// +// Ошибка чтения старых долей НЕ проглатывается, в отличие от override'ов и +// категорий: пересчёт без них затрёт накопленное. Дешевле не пересчитать — +// сам лимит уже сохранён и применится при следующем приходе. +func (s *BudgetSkill) keepCarriedIn( + ctx context.Context, + chatID int64, + envelopeID uuid.UUID, + shares []budget.EnvelopeShare, +) ([]budget.EnvelopeShare, error) { + old, err := s.store.ListShares(ctx, chatID, envelopeID) + if err != nil { + return nil, fmt.Errorf("доли текущего конверта: %w", err) + } + carried := make([]safetospend.CarriedAmount, 0, len(old)) + for _, sh := range old { + if sh.CarriedIn <= 0 { + continue + } + carried = append(carried, safetospend.CarriedAmount{ + Name: sh.Name, + Amount: sh.CarriedIn, + Source: sh.Source, + Categories: sh.Categories, + }) + } + return safetospend.ApplyCarry(shares, carried), nil +} + +// replan — результат пересчёта: сама раскладка плюс валюта, которой её +// печатать. Валюта показа идёт рядом с планом, а не берётся форматтером заново: +// печать обязана показывать ТЕ ЖЕ деньги, по которым посчитаны доли. +type replan struct { + plan safetospend.EnvelopePlan + display safetospend.Display +} + +// formatReplan печатает пересчитанную раскладку. Форматирование локальное и +// короткое: полный разбор конверта (приход → обязательства → доли) оператор уже +// видел при start_envelope, здесь нужен только результат правки. +func formatReplan(r replan) string { + m := r.display + var b strings.Builder + b.WriteString("Пересчитал текущий конверт:\n") + // Регулярные платежи показываем наравне с гибкими конвертами: делить траты + // на «обязательные» и «на жизнь» оператор запретил (simpleAI-faeq.11 §1). + for _, sh := range safetospend.FixedShares(r.plan.Shares) { + fmt.Fprintf(&b, " • %s — %s (платёж)\n", sh.Name, m.Fmt(sh.Allocated)) + } + for _, sh := range safetospend.SpendShares(r.plan.Shares) { + mark := "" + if sh.Source == budget.ShareSourceOverride { + mark = " (вручную)" + } + fmt.Fprintf(&b, " • %s — %s%s\n", sh.Name, m.Fmt(sh.Allocated), mark) + } + for _, sh := range safetospend.SaveShares(r.plan.Shares) { + fmt.Fprintf(&b, " 💰 %s — %s\n", sh.Name, m.Fmt(sh.Allocated)) + } + for _, w := range r.plan.Warnings { + fmt.Fprintf(&b, "⚠️ %s\n", w) + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/internal/skills/budget/limits_test.go b/internal/skills/budget/limits_test.go new file mode 100644 index 0000000..aaab802 --- /dev/null +++ b/internal/skills/budget/limits_test.go @@ -0,0 +1,487 @@ +package budgetskill + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "simpleAI/internal/agent" + "simpleAI/internal/budget" +) + +// Ручная правка лимита конверта (ADR-008 §2, задача simpleAI-faeq.8). +// +// Тесты интеграционные: проверяемое поведение — «правка сохранилась в базе И +// применилась к раскладке», и оба слагаемых наблюдаемы только через БД. Мок +// стора здесь доказывал бы только то, что мок вызвали. + +// shareLimitEnv поднимает стор к реплике. Без write-доступа тест пропускается: +// правка лимита ПИШЕТ (override + перезапись долей), read-only реплика её не +// проверит. +func shareLimitEnv(t *testing.T) (*budget.Store, *pgxpool.Pool) { + t.Helper() + url := os.Getenv("BOTCLIENT_DATABASE_URL_RW") + if url == "" { + t.Skip("BOTCLIENT_DATABASE_URL_RW не задан — write-доступ к реплике недоступен") + } + pool, err := pgxpool.New(context.Background(), url) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(pool.Close) + return budget.NewStore(pool), pool +} + +func cleanupChat(t *testing.T, pool *pgxpool.Pool, chatID int64) { + t.Helper() + ctx := context.Background() + if _, err := pool.Exec(ctx, `DELETE FROM budget_envelope WHERE chat_id = $1`, chatID); err != nil { + t.Logf("cleanup budget_envelope: %v", err) + } + if _, err := pool.Exec(ctx, `DELETE FROM budget_envelope_limit_override WHERE chat_id = $1`, chatID); err != nil { + t.Logf("cleanup override: %v", err) + } +} + +// findShare ищет долю по имени регистронезависимо — имя доли приходит из +// прогноза в отображаемом регистре («Еда»), а оператор называет её как угодно. +func findShare(shares []budget.EnvelopeShare, name string) (budget.EnvelopeShare, bool) { + for _, sh := range shares { + if strings.EqualFold(strings.TrimSpace(sh.Name), name) { + return sh, true + } + } + return budget.EnvelopeShare{}, false +} + +func mustRun(t *testing.T, skill *BudgetSkill, ctx context.Context, input string) string { + t.Helper() + out, err := skill.Run(ctx, input) + if err != nil { + t.Fatalf("Run(%s): %v", input, err) + } + return out +} + +// overrideTHB — сколько THB должен получить конверт с ручным лимитом amount в +// currency. Считается ТЕМ ЖЕ курсом, что и раскладка: сверять с константой +// значило бы вшить курс дня в тест. +func overrideTHB(t *testing.T, store *budget.Store, amount float64, currency string) float64 { + t.Helper() + rates, err := store.GetExchangeRates(context.Background()) + if err != nil { + t.Fatalf("курсы: %v", err) + } + thb, ok := budget.ToTHB(amount, currency, rates) + if !ok { + t.Fatalf("нет курса %s", currency) + } + return thb +} + +// «на еду хватит 15000»: правка обязана и сохраниться правилом (override в базе), +// и немедленно применяться к ТЕКУЩЕМУ конверту — оператор поправил лимит, чтобы +// пользоваться им сейчас, а не со следующего прихода. +func TestSetShareLimit_SavesOverrideAndReplansActiveEnvelope(t *testing.T) { + store, pool := shareLimitEnv(t) + const chatID = int64(-70031) + cleanupChat(t, pool, chatID) + t.Cleanup(func() { cleanupChat(t, pool, chatID) }) + + ctx := context.WithValue(context.Background(), agent.ChatIDKey{}, chatID) + from := time.Now() + envID, err := store.CreateEnvelope(ctx, chatID, 300000, "RUB", from, from.AddDate(0, 0, 14)) + if err != nil { + t.Fatalf("CreateEnvelope: %v", err) + } + + skill := NewBudgetSkill(store) + reply := mustRun(t, skill, ctx, `{"action":"set_share_limit","name":"еда","amount":15000,"currency":"RUB"}`) + if !strings.Contains(reply, "еда") { + t.Errorf("в ответе нет имени конверта: %q", reply) + } + + // 1. Правило сохранено — иначе оно не переживёт текущий конверт. + overrides, err := store.ListOverrides(ctx, chatID) + if err != nil { + t.Fatalf("ListOverrides: %v", err) + } + if len(overrides) != 1 || overrides[0].ShareName != "еда" || overrides[0].Amount != 15000 || overrides[0].Currency != "RUB" { + t.Fatalf("ожидали один override «еда» 15000 RUB, got %+v", overrides) + } + + // 2. Текущий конверт пересчитан — доля «еда» ровно на заданную сумму. + shares, err := store.ListShares(ctx, chatID, envID) + if err != nil { + t.Fatalf("ListShares: %v", err) + } + if len(shares) == 0 { + t.Fatal("после правки лимита у конверта нет ни одной доли — раскладку не перезаписали") + } + food, ok := findShare(shares, "еда") + if !ok { + t.Fatalf("в пересчитанной раскладке нет доли «еда»: %+v", shareNames(shares)) + } + if food.Source != budget.ShareSourceOverride { + t.Errorf("доля «еда» должна быть помечена как ручная, got source=%q", food.Source) + } + want := overrideTHB(t, store, 15000, "RUB") + if diff := food.Allocated - want; diff > 0.01 || diff < -0.01 { + t.Errorf("лимит доли «еда» = %.2f ฿, ожидали %.2f ฿", food.Allocated, want) + } +} + +// «убери лимит на еду»: правило удаляется, а конверт пересчитывается обратно — +// доля перестаёт быть ручной. Проверяем оба следствия: удалённая строка без +// пересчёта оставила бы на экране старый лимит. +func TestClearShareLimit_DeletesOverrideAndReplans(t *testing.T) { + store, pool := shareLimitEnv(t) + const chatID = int64(-70032) + cleanupChat(t, pool, chatID) + t.Cleanup(func() { cleanupChat(t, pool, chatID) }) + + ctx := context.WithValue(context.Background(), agent.ChatIDKey{}, chatID) + from := time.Now() + envID, err := store.CreateEnvelope(ctx, chatID, 300000, "RUB", from, from.AddDate(0, 0, 14)) + if err != nil { + t.Fatalf("CreateEnvelope: %v", err) + } + + skill := NewBudgetSkill(store) + mustRun(t, skill, ctx, `{"action":"set_share_limit","name":"еда","amount":15000,"currency":"RUB"}`) + mustRun(t, skill, ctx, `{"action":"clear_share_limit","name":"еда"}`) + + overrides, err := store.ListOverrides(ctx, chatID) + if err != nil { + t.Fatalf("ListOverrides: %v", err) + } + if len(overrides) != 0 { + t.Fatalf("после снятия лимита override'ов быть не должно, got %+v", overrides) + } + + shares, err := store.ListShares(ctx, chatID, envID) + if err != nil { + t.Fatalf("ListShares: %v", err) + } + if len(shares) == 0 { + t.Fatal("после снятия лимита у конверта нет ни одной доли — раскладку не перезаписали") + } + if food, ok := findShare(shares, "еда"); ok && food.Source == budget.ShareSourceOverride { + t.Errorf("доля «еда» осталась ручной после снятия лимита: %+v", food) + } +} + +// Главное свойство правки: она — ПРАВИЛО, а не разовая подмена. Следующий приход +// раскладывается уже с ней, хотя конверт другой и раскладка считается заново. +// +// Мутация, под которую написан тест: если setShareLimit перестанет писать +// override в базу и ограничится пересчётом текущего конверта, этот тест краснеет +// (в новом конверте доля «еда» вернётся к авто-лимиту), а два предыдущих — нет. +func TestShareLimitOverride_SurvivesNextEnvelope(t *testing.T) { + store, pool := shareLimitEnv(t) + const chatID = int64(-70033) + cleanupChat(t, pool, chatID) + t.Cleanup(func() { cleanupChat(t, pool, chatID) }) + + ctx := context.WithValue(context.Background(), agent.ChatIDKey{}, chatID) + skill := NewBudgetSkill(store) + + from := time.Now() + if _, err := store.CreateEnvelope(ctx, chatID, 300000, "RUB", from, from.AddDate(0, 0, 14)); err != nil { + t.Fatalf("CreateEnvelope: %v", err) + } + mustRun(t, skill, ctx, `{"action":"set_share_limit","name":"еда","amount":15000,"currency":"RUB"}`) + + // Следующий приход — обычным путём оператора, а не подкладыванием строк в БД. + mustRun(t, skill, ctx, `{"action":"start_envelope","amount":300000,"currency":"RUB"}`) + + env, ok, err := store.GetActiveEnvelope(ctx, chatID) + if err != nil || !ok { + t.Fatalf("активный конверт после нового прихода: ok=%v err=%v", ok, err) + } + shares, err := store.ListShares(ctx, chatID, env.ID) + if err != nil { + t.Fatalf("ListShares: %v", err) + } + food, ok := findShare(shares, "еда") + if !ok { + t.Fatalf("в новом конверте нет доли «еда»: %v", shareNames(shares)) + } + if food.Source != budget.ShareSourceOverride { + t.Fatalf("ручной лимит не пережил новый приход: доля «еда» source=%q, allocated=%.2f", food.Source, food.Allocated) + } + want := overrideTHB(t, store, 15000, "RUB") + if diff := food.Allocated - want; diff > 0.01 || diff < -0.01 { + t.Errorf("в новом конверте лимит «еда» = %.2f ฿, ожидали %.2f ฿", food.Allocated, want) + } +} + +// carried_in — факт прошлого периода (ADR-008 §9), а не результат раскладки. +// Правка лимита перезаписывает доли целиком, поэтому перенос обязан быть +// сохранён явно: иначе одна фраза «на еду хватит 15000» молча обнулила бы +// накопленное в конверте «Отпуск». +func TestSetShareLimit_KeepsCarriedIn(t *testing.T) { + store, pool := shareLimitEnv(t) + const chatID = int64(-70034) + cleanupChat(t, pool, chatID) + t.Cleanup(func() { cleanupChat(t, pool, chatID) }) + + ctx := context.WithValue(context.Background(), agent.ChatIDKey{}, chatID) + from := time.Now() + envID, err := store.CreateEnvelope(ctx, chatID, 300000, "RUB", from, from.AddDate(0, 0, 14)) + if err != nil { + t.Fatalf("CreateEnvelope: %v", err) + } + if err := store.CreateShares(ctx, envID, []budget.EnvelopeShare{ + {Name: "Отпуск", Kind: budget.ShareKindSave, Allocated: 1000, CarriedIn: 2500, Source: budget.ShareSourceOverride, Position: 0}, + {Name: budget.FallbackShareName, Kind: budget.ShareKindSpend, Allocated: 0, Source: budget.ShareSourceAuto, Position: 1}, + }); err != nil { + t.Fatalf("CreateShares: %v", err) + } + if err := store.SetOverride(ctx, chatID, "отпуск", 1000, "THB"); err != nil { + t.Fatalf("SetOverride: %v", err) + } + + skill := NewBudgetSkill(store) + mustRun(t, skill, ctx, `{"action":"set_share_limit","name":"еда","amount":15000,"currency":"RUB"}`) + + shares, err := store.ListShares(ctx, chatID, envID) + if err != nil { + t.Fatalf("ListShares: %v", err) + } + vacation, ok := findShare(shares, "отпуск") + if !ok { + t.Fatalf("доля «Отпуск» исчезла после пересчёта: %v", shareNames(shares)) + } + if vacation.CarriedIn != 2500 { + t.Errorf("перенос с прошлого конверта обнулён: carried_in = %.2f, ожидали 2500", vacation.CarriedIn) + } +} + +// Тот же перенос, но у доли, которой в свежей раскладке НЕТ и быть не может: +// override на «Отпуск» не стоит, из истории трат такая доля не считается, и +// PlanEnvelope её не выдаёт — доля живёт исключительно ради перенесённых денег +// (CarryOver заводит её принудительно, ADR-008 §9). +// +// Это и есть дыра, которую тест выше не ловил: там «Отпуск» держался override'ом +// и приходил из раскладки сам. Здесь ReplaceShares вынес бы долю целиком вместе +// с накопленным — молча, без единой строки в ответе. +// +// Мутация, под которую написан тест: вернуть keepCarriedIn к расстановке +// carried_in по именам уже существующих долей — тест краснеет («Отпуск» исчезает). +func TestSetShareLimit_KeepsCarriedInForShareOutsidePlan(t *testing.T) { + store, pool := shareLimitEnv(t) + const chatID = int64(-70038) + cleanupChat(t, pool, chatID) + t.Cleanup(func() { cleanupChat(t, pool, chatID) }) + + ctx := context.WithValue(context.Background(), agent.ChatIDKey{}, chatID) + from := time.Now() + envID, err := store.CreateEnvelope(ctx, chatID, 300000, "RUB", from, from.AddDate(0, 0, 14)) + if err != nil { + t.Fatalf("CreateEnvelope: %v", err) + } + // Доля-носитель переноса: allocated=0, живёт только ради carried_in — ровно + // в таком виде её оставляет CarryOver при заведении конверта. + if err := store.CreateShares(ctx, envID, []budget.EnvelopeShare{ + {Name: "Отпуск", Kind: budget.ShareKindSave, Allocated: 0, CarriedIn: 2500, Source: budget.ShareSourceAuto, Position: 0}, + {Name: budget.FallbackShareName, Kind: budget.ShareKindSpend, Allocated: 0, Source: budget.ShareSourceAuto, Position: 1}, + }); err != nil { + t.Fatalf("CreateShares: %v", err) + } + // Проверка предпосылки: override'ов нет вовсе, иначе тест доказывал бы не то. + if overrides, err := store.ListOverrides(ctx, chatID); err != nil { + t.Fatalf("ListOverrides: %v", err) + } else if len(overrides) != 0 { + t.Fatalf("предпосылка сломана: у чата уже есть override'ы %+v", overrides) + } + + skill := NewBudgetSkill(store) + // Правка ПО ДРУГОЙ категории — «Отпуск» тут вообще ни при чём. + mustRun(t, skill, ctx, `{"action":"set_share_limit","name":"еда","amount":15000,"currency":"RUB"}`) + + shares, err := store.ListShares(ctx, chatID, envID) + if err != nil { + t.Fatalf("ListShares: %v", err) + } + vacation, ok := findShare(shares, "отпуск") + if !ok { + t.Fatalf("доля «Отпуск» вынесена пересчётом вместе с накопленным: %v", shareNames(shares)) + } + if vacation.CarriedIn != 2500 { + t.Errorf("перенос обнулён: carried_in = %.2f, ожидали 2500", vacation.CarriedIn) + } + if vacation.Kind != budget.ShareKindSave { + t.Errorf("доля-носитель переноса должна остаться накопительной, got kind=%q", vacation.Kind) + } + + // И то же самое при снятии лимита — второй путь в тот же пересчёт. + mustRun(t, skill, ctx, `{"action":"clear_share_limit","name":"еда"}`) + shares, err = store.ListShares(ctx, chatID, envID) + if err != nil { + t.Fatalf("ListShares после clear: %v", err) + } + vacation, ok = findShare(shares, "отпуск") + if !ok { + t.Fatalf("после снятия лимита доля «Отпуск» исчезла: %v", shareNames(shares)) + } + if vacation.CarriedIn != 2500 { + t.Errorf("после снятия лимита перенос = %.2f, ожидали 2500", vacation.CarriedIn) + } +} + +// Правка без активного конверта — не ошибка: правило сохраняется и ждёт прихода. +func TestSetShareLimit_NoActiveEnvelope(t *testing.T) { + store, pool := shareLimitEnv(t) + const chatID = int64(-70035) + cleanupChat(t, pool, chatID) + t.Cleanup(func() { cleanupChat(t, pool, chatID) }) + + ctx := context.WithValue(context.Background(), agent.ChatIDKey{}, chatID) + skill := NewBudgetSkill(store) + reply := mustRun(t, skill, ctx, `{"action":"set_share_limit","name":"еда","amount":15000,"currency":"RUB"}`) + if !strings.Contains(reply, "следующий приход") { + t.Errorf("ответ должен объяснять, что лимит применится к следующему приходу: %q", reply) + } + overrides, err := store.ListOverrides(ctx, chatID) + if err != nil { + t.Fatalf("ListOverrides: %v", err) + } + if len(overrides) != 1 { + t.Fatalf("ожидали сохранённый override, got %+v", overrides) + } +} + +// Чужой конверт нельзя переписать даже с валидным envelope_id (ADR-004): у долей +// своего chat_id нет, изоляция держится только этой проверкой. +func TestReplaceShares_ForeignEnvelopeRejected(t *testing.T) { + store, pool := shareLimitEnv(t) + const chatA, chatB = int64(-70036), int64(-70037) + cleanupChat(t, pool, chatA) + cleanupChat(t, pool, chatB) + t.Cleanup(func() { cleanupChat(t, pool, chatA); cleanupChat(t, pool, chatB) }) + + ctx := context.Background() + from := time.Now() + envA, err := store.CreateEnvelope(ctx, chatA, 100000, "RUB", from, from.AddDate(0, 0, 14)) + if err != nil { + t.Fatalf("CreateEnvelope: %v", err) + } + err = store.ReplaceShares(ctx, chatB, envA, []budget.EnvelopeShare{ + {Name: budget.FallbackShareName, Kind: budget.ShareKindSpend, Source: budget.ShareSourceAuto}, + }) + if err == nil { + t.Fatal("чужой конверт переписан — изоляция по chat_id не работает") + } + if _, err := store.ListShares(ctx, chatA, envA); err != nil { + t.Fatalf("ListShares: %v", err) + } + if err := store.ReplaceShares(ctx, chatA, uuid.Nil, nil); err == nil { + t.Error("пустой envelopeID должен отбиваться") + } +} + +// shareNames — имена долей для сообщений об ошибке. +func shareNames(shares []budget.EnvelopeShare) []string { + out := make([]string, 0, len(shares)) + for _, sh := range shares { + out = append(out, fmt.Sprintf("%s(%s,%.0f)", sh.Name, sh.Source, sh.Allocated)) + } + return out +} + +// Лимит, названный в рублях, и лимит, названный в батах, при одном курсе дают +// ОДНУ И ТУ ЖЕ долю: 15000 ₽ при 3 ₽/฿ — это те же 5000 ฿. Проверяется место +// единственной конвертации (overridesToTHB): если бы курс применялся ещё и при +// записи override'а, рублёвый лимит пришёл бы сюда уже в батах и «сконвертился» +// второй раз — доля стала бы втрое меньше. +func TestOverridesToTHB_SameLimitInBothCurrencies(t *testing.T) { + rates := map[string]float64{"RUB": 1, "THB": 3} + + got := overridesToTHB([]budget.EnvelopeOverride{ + {ShareName: "еда", Amount: 15000, Currency: "RUB"}, + {ShareName: "транспорт", Amount: 5000, Currency: "THB"}, + }, rates) + + if got["еда"] != 5000 { + t.Errorf("лимит 15000 ₽ при курсе 3 ₽/฿ = 5000 ฿, получили %v", got["еда"]) + } + if got["транспорт"] != 5000 { + t.Errorf("лимит 5000 ฿ обязан остаться 5000 ฿, получили %v", got["транспорт"]) + } + if got["еда"] != got["транспорт"] { + t.Errorf("один и тот же лимит в разных валютах дал разные доли: %v vs %v", + got["еда"], got["транспорт"]) + } +} + +// Валюта без курса не превращается в баты молча: доля просто остаётся +// авто-лимитом, а не получает выдуманное число. +func TestOverridesToTHB_UnknownCurrencySkipped(t *testing.T) { + got := overridesToTHB([]budget.EnvelopeOverride{ + {ShareName: "еда", Amount: 100, Currency: "XXX"}, + }, map[string]float64{"RUB": 1, "THB": 3}) + if _, ok := got["еда"]; ok { + t.Errorf("override без курса попал в раскладку: %v", got) + } +} + +// Лимит словами в рублях и лимит словами в батах, задающие одну и ту же сумму +// по текущему курсу, обязаны дать ОДНУ И ТУ ЖЕ долю в базе (доли хранятся в +// THB, ADR-008 §7). Тест сквозной — от входа скилла до записанной доли: +// именно на этом пути и может задвоиться курс, чего чистая функция +// overridesToTHB одна не покажет. +func TestSetShareLimit_SameLimitInRubAndBaht(t *testing.T) { + store, pool := shareLimitEnv(t) + const chatID = int64(-70039) + cleanupChat(t, pool, chatID) + t.Cleanup(func() { cleanupChat(t, pool, chatID) }) + + ctx := context.WithValue(context.Background(), agent.ChatIDKey{}, chatID) + from := time.Now() + envID, err := store.CreateEnvelope(ctx, chatID, 300000, "RUB", from, from.AddDate(0, 0, 14)) + if err != nil { + t.Fatalf("CreateEnvelope: %v", err) + } + skill := NewBudgetSkill(store) + + // Сколько бат в 15000 ₽ по текущему курсу — ту же сумму задаём батами. + baht := overrideTHB(t, store, 15000, "RUB") + + mustRun(t, skill, ctx, `{"action":"set_share_limit","name":"еда","amount":15000,"currency":"RUB"}`) + inRub := mustFindShare(t, store, ctx, chatID, envID, "еда").Allocated + + mustRun(t, skill, ctx, fmt.Sprintf( + `{"action":"set_share_limit","name":"еда","amount":%.4f,"currency":"THB"}`, baht)) + inBaht := mustFindShare(t, store, ctx, chatID, envID, "еда").Allocated + + if diff := inRub - inBaht; diff > 0.01 || diff < -0.01 { + t.Fatalf("один и тот же лимит дал разные доли: %.2f ฿ (задан рублями) vs %.2f ฿ (задан батами)", + inRub, inBaht) + } + // Батовый лимит не должен «сконвертиться» второй раз: сумма в базе равна + // названной. Именно так выглядело бы задвоение курса. + if diff := inBaht - baht; diff > 0.01 || diff < -0.01 { + t.Fatalf("лимит %.2f ฿ записан как %.2f ฿ — курс применён лишний раз", baht, inBaht) + } +} + +func mustFindShare(t *testing.T, store *budget.Store, ctx context.Context, + chatID int64, envID uuid.UUID, name string) budget.EnvelopeShare { + t.Helper() + shares, err := store.ListShares(ctx, chatID, envID) + if err != nil { + t.Fatalf("ListShares: %v", err) + } + sh, ok := findShare(shares, name) + if !ok { + t.Fatalf("в раскладке нет доли %q: %+v", name, shareNames(shares)) + } + return sh +} diff --git a/internal/skills/budget/share_warning.go b/internal/skills/budget/share_warning.go new file mode 100644 index 0000000..cf94d19 --- /dev/null +++ b/internal/skills/budget/share_warning.go @@ -0,0 +1,114 @@ +package budgetskill + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "simpleAI/internal/agent" + "simpleAI/internal/budget" + "simpleAI/internal/skills/safetospend" +) + +// shareWarningStore — узкий read-only контракт для предупреждения о пробитом +// конверте. Отдельный интерфейс, а не *budget.Store, ровно ради одного +// инварианта: «ошибка расчёта не ломает запись траты» проверяется тестом с +// падающим стором, а не рассуждением. +type shareWarningStore interface { + GetActiveEnvelope(ctx context.Context, chatID int64) (*budget.Envelope, bool, error) + ListShares(ctx context.Context, chatID int64, envelopeID uuid.UUID) ([]budget.EnvelopeShare, error) + GetExchangeRates(ctx context.Context) (map[string]float64, error) + SpentByCategoryExcludingRecurring(ctx context.Context, from, to time.Time) ([]budget.CategorySpentRow, error) +} + +// shareOverspendWarning — строка-предупреждение, что трата пробила долю +// активного конверта (ADR-008 §8). +// +// Контракт вызова жёсткий: функция ТОЛЬКО дополняет ответ. Транзакция уже +// записана, откатывать и блокировать её нельзя — конверт это план, а не +// разрешение тратить. Любая невозможность посчитать (нет конверта, нет курса, +// ошибка БД) даёт пустую строку и обычный ответ; ошибка при этом логируется, +// но наверх не поднимается. +// +// Остаток НЕ считается здесь заново: он берётся из safetospend.ShareRemainingFor +// поверх computeShareRemaining — единственного места, где живёт формула +// remaining = allocated + carried_in − факт (ADR-008 §8). +func (s *BudgetSkill) shareOverspendWarning(ctx context.Context, t budget.Transaction, req budgetInput) string { + st := s.shareStore + if st == nil { + return "" + } + chatID, ok := ctx.Value(agent.ChatIDKey{}).(int64) + if !ok || chatID == 0 { + return "" + } + + // Трата, которой по построению нет места ни в одной доле, не может её + // пробить. computeShareRemaining берёт в факт только ПЕРЕМЕННЫЕ ежедневные + // траты (ADR-008 §4) и только без recurring_id (§5, отсекает + // SpentByCategoryExcludingRecurring). Без этой проверки «Переводы» или + // платёж по recurring маршрутизировались бы в fallback-долю «прочее» и + // печатали чужой пробой, ничего в этот конверт не внося. + if t.RecurringID != nil || !budget.IsVariableDailyExpense(t.CategoryName) { + return "" + } + + env, found, err := st.GetActiveEnvelope(ctx, chatID) + if err != nil { + slog.Default().WarnContext(ctx, "add_expense: активный конверт не прочитан, предупреждение пропущено", + "err", err, "chat_id", chatID) + return "" + } + if !found || env == nil { + return "" // конвертов нет — предупреждать не о чем + } + + shares, err := st.ListShares(ctx, chatID, env.ID) + if err != nil || len(shares) == 0 { + if err != nil { + slog.Default().WarnContext(ctx, "add_expense: доли конверта не прочитаны", "err", err, "chat_id", chatID) + } + return "" + } + + rates, err := st.GetExchangeRates(ctx) + if err != nil || rates["THB"] == 0 { + slog.Default().WarnContext(ctx, "add_expense: нет курса, остаток доли не посчитан", "err", err) + return "" + } + + // Верхняя граница факта — по сегодня: конверт может кончаться в будущем, а + // факта из будущего не бывает (та же обрезка, что в startEnvelope). + to := time.Now() + if to.After(env.PeriodEnd) { + to = env.PeriodEnd + } + if to.Before(env.PeriodStart) { + to = env.PeriodStart + } + spent, err := st.SpentByCategoryExcludingRecurring(ctx, env.PeriodStart, to) + if err != nil { + slog.Default().WarnContext(ctx, "add_expense: факт по категориям не прочитан", "err", err) + return "" + } + + rem, ok := safetospend.ShareRemainingFor(shares, spent, rates, t.CategoryID, t.CategoryName) + if !ok { + return "" // трате некуда падать — нет даже fallback-доли (ADR-008 §6) + } + return formatShareWarning(rem, envelopeDisplay(req, rates)) +} + +// formatShareWarning — текст предупреждения. Чистая функция: показывать нечего, +// пока доля не пробита. Валюта — та же, что и во всём ответе про конверты +// (по умолчанию баты); хранятся доли всё равно в THB (ADR-008 §7). +func formatShareWarning(rem safetospend.ShareRemaining, m safetospend.Display) string { + if !rem.Overspent() { + return "" + } + return fmt.Sprintf("\n\n⚠️ Конверт «%s» пробит на %s (лимит %s, потрачено %s).", + rem.Name, m.Fmt(-rem.Remaining), m.Fmt(rem.LimitTHB), m.Fmt(rem.SpentTHB)) +} diff --git a/internal/skills/budget/share_warning_test.go b/internal/skills/budget/share_warning_test.go new file mode 100644 index 0000000..592bd01 --- /dev/null +++ b/internal/skills/budget/share_warning_test.go @@ -0,0 +1,332 @@ +package budgetskill + +import ( + "context" + "errors" + "strconv" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "simpleAI/internal/agent" + "simpleAI/internal/budget" +) + +const warnChatID = int64(-70047) + +// fakeShareStore — подставной источник конверта и факта. Позволяет проверить +// предупреждение без БД и, главное, смоделировать сбой расчёта. +type fakeShareStore struct { + env *budget.Envelope + shares []budget.EnvelopeShare + spent []budget.CategorySpentRow + rates map[string]float64 + err error // сбой любого чтения +} + +func (f *fakeShareStore) GetActiveEnvelope(context.Context, int64) (*budget.Envelope, bool, error) { + if f.err != nil { + return nil, false, f.err + } + if f.env == nil { + return nil, false, nil + } + return f.env, true, nil +} + +func (f *fakeShareStore) ListShares(context.Context, int64, uuid.UUID) ([]budget.EnvelopeShare, error) { + if f.err != nil { + return nil, f.err + } + return f.shares, nil +} + +func (f *fakeShareStore) GetExchangeRates(context.Context) (map[string]float64, error) { + if f.err != nil { + return nil, f.err + } + return f.rates, nil +} + +func (f *fakeShareStore) SpentByCategoryExcludingRecurring(context.Context, time.Time, time.Time) ([]budget.CategorySpentRow, error) { + if f.err != nil { + return nil, f.err + } + return f.spent, nil +} + +// warnRates — курс 1 ฿ = 2 ₽, RUB к самому себе 1. Числа круглые нарочно: +// величина перерасхода в тексте должна читаться глазом. +func warnRates() map[string]float64 { + return map[string]float64{"THB": 2, "RUB": 1} +} + +func warnEnvelope() *budget.Envelope { + now := time.Now() + return &budget.Envelope{ + ID: uuid.New(), + ChatID: warnChatID, + PeriodStart: now.AddDate(0, 0, -7), + PeriodEnd: now.AddDate(0, 0, 7), + } +} + +// warnShares — «Еда» с лимитом 5000 ฿ и обязательная доля-приёмник «прочее» +// с лимитом 1000 ฿ (ADR-008 §6). +func warnShares() []budget.EnvelopeShare { + return []budget.EnvelopeShare{ + { + Name: "Еда", Kind: budget.ShareKindSpend, Source: budget.ShareSourceAuto, + Allocated: 5000, Position: 0, + Categories: []budget.EnvelopeShareCategory{{CategoryName: "еда"}}, + }, + { + Name: budget.FallbackShareName, Kind: budget.ShareKindSpend, Source: budget.ShareSourceAuto, + Allocated: 1000, Position: 1, + }, + } +} + +func warnCtx() context.Context { + return context.WithValue(context.Background(), agent.ChatIDKey{}, warnChatID) +} + +func warnSkill(st *fakeShareStore) *BudgetSkill { + return &BudgetSkill{buckets: defaultBuckets(), shareStore: st} +} + +// TestShareWarning_WithinLimit — трата в пределах лимита: ответ без строки о +// пробое. +func TestShareWarning_WithinLimit(t *testing.T) { + st := &fakeShareStore{ + env: warnEnvelope(), shares: warnShares(), rates: warnRates(), + spent: []budget.CategorySpentRow{{CategoryName: "Еда", Currency: "RUB", Amount: 4000}}, // 2000 ฿ из 5000 + } + got := warnSkill(st).shareOverspendWarning(warnCtx(), budget.Transaction{CategoryName: "Еда"}, budgetInput{}) + if got != "" { + t.Fatalf("ожидали пустое предупреждение, получили %q", got) + } +} + +// TestShareWarning_Overspent — трата увела долю в минус: в ответе имя доли и +// величина перерасхода. Валюты в запросе нет — печатаем батами (дефолт): +// лимит 5000 ฿, потрачено 12000 ₽ = 6000 ฿ → минус 1000 ฿. +func TestShareWarning_Overspent(t *testing.T) { + st := &fakeShareStore{ + env: warnEnvelope(), shares: warnShares(), rates: warnRates(), + spent: []budget.CategorySpentRow{{CategoryName: "Еда", Currency: "RUB", Amount: 12000}}, + } + got := warnSkill(st).shareOverspendWarning(warnCtx(), budget.Transaction{CategoryName: "Еда"}, budgetInput{}) + if !strings.Contains(got, "Еда") { + t.Fatalf("нет имени доли в предупреждении: %q", got) + } + if !strings.Contains(got, "1 000 ฿") { + t.Fatalf("нет величины перерасхода 1000 ฿: %q", got) + } + if strings.Contains(got, "₽") { + t.Fatalf("без просьбы о рублях предупреждение обязано быть в батах: %q", got) + } +} + +// TestShareWarning_DisplayRUB — оператор попросил рубли: то же предупреждение +// печатается рублями. Тот же перерасход 1000 ฿ = 2000 ₽ при курсе 2 ₽/฿. +func TestShareWarning_DisplayRUB(t *testing.T) { + st := &fakeShareStore{ + env: warnEnvelope(), shares: warnShares(), rates: warnRates(), + spent: []budget.CategorySpentRow{{CategoryName: "Еда", Currency: "RUB", Amount: 12000}}, + } + got := warnSkill(st).shareOverspendWarning(warnCtx(), + budget.Transaction{CategoryName: "Еда"}, budgetInput{DisplayCurrency: "RUB"}) + if !strings.Contains(got, "2 000 ₽") { + t.Fatalf("нет величины перерасхода 2000 ₽: %q", got) + } + if strings.Contains(got, "฿") { + t.Fatalf("просили рубли, а в ответе баты: %q", got) + } +} + +// TestShareWarning_FallbackShare — категория без своей доли попадает в +// «прочее», и предупреждение считается по доле-приёмнику (ADR-008 §6). +// Лимит «прочее» 1000 ฿, потрачено 3000 ₽ = 1500 ฿ → минус 500 ฿. +func TestShareWarning_FallbackShare(t *testing.T) { + st := &fakeShareStore{ + env: warnEnvelope(), shares: warnShares(), rates: warnRates(), + spent: []budget.CategorySpentRow{{CategoryName: "Развлечения", Currency: "RUB", Amount: 3000}}, + } + got := warnSkill(st).shareOverspendWarning(warnCtx(), budget.Transaction{CategoryName: "Развлечения"}, budgetInput{}) + if !strings.Contains(got, budget.FallbackShareName) { + t.Fatalf("предупреждение не по доле-приёмнику: %q", got) + } + if !strings.Contains(got, "500 ฿") { + t.Fatalf("нет величины перерасхода 500 ฿: %q", got) + } + // Доля «Еда» не должна упоминаться: её лимит трата не трогала. + if strings.Contains(got, "Еда") { + t.Fatalf("трата без своей доли задела чужую долю: %q", got) + } +} + +// TestShareWarning_NoActiveEnvelope — нет активного конверта: ответ прежний. +func TestShareWarning_NoActiveEnvelope(t *testing.T) { + st := &fakeShareStore{rates: warnRates()} + if got := warnSkill(st).shareOverspendWarning(warnCtx(), budget.Transaction{CategoryName: "Еда"}, budgetInput{}); got != "" { + t.Fatalf("без конверта ожидали пустой ответ, получили %q", got) + } +} + +// TestShareWarning_ComputeFailureIsSilent — сбой расчёта не поднимается наверх: +// предупреждения нет, паники нет, запись траты не затронута. +func TestShareWarning_ComputeFailureIsSilent(t *testing.T) { + st := &fakeShareStore{err: errors.New("боль в БД")} + if got := warnSkill(st).shareOverspendWarning(warnCtx(), budget.Transaction{CategoryName: "Еда"}, budgetInput{}); got != "" { + t.Fatalf("при сбое ожидали пустой ответ, получили %q", got) + } +} + +// TestAddExpense_WarnsOnOverspentShare_Integration — сквозной прогон через +// настоящий add_expense: трата пишется в БД, а предупреждение приклеивается +// поверх обычного ответа. +// +// Второй, не менее важный кусок — сбой расчёта: с падающим shareStore ответ +// прежний, но транзакция всё равно в базе. Именно это доказывает инвариант +// «предупреждение не блокирует и не откатывает трату». +func TestAddExpense_WarnsOnOverspentShare_Integration(t *testing.T) { + skill, pool, ctx := newEnvelopeTestSkill(t) + + var created []string + t.Cleanup(func() { + for _, id := range created { + if _, err := pool.Exec(context.Background(), `DELETE FROM budget_transaction WHERE id = $1`, id); err != nil { + t.Logf("cleanup transaction %s: %v", id, err) + } + } + }) + addExpense := func(amount int) string { + t.Helper() + reply, err := skill.Run(ctx, `{"action":"add_expense","amount":`+itoa(amount)+`,"currency":"RUB","category":"Еда","description":"share-warning-test"}`) + if err != nil { + t.Fatalf("add_expense: %v", err) + } + var id string + if err := pool.QueryRow(context.Background(), + `SELECT id::text FROM budget_transaction WHERE description = 'share-warning-test' ORDER BY created_at DESC LIMIT 1`).Scan(&id); err != nil { + t.Fatalf("трата не записана: %v", err) + } + created = append(created, id) + return reply + } + + if _, err := skill.Run(ctx, `{"action":"start_envelope","amount":127000,"currency":"RUB"}`); err != nil { + t.Fatalf("start_envelope: %v", err) + } + env, ok, err := skill.store.GetActiveEnvelope(ctx, envelopeTestChatID) + if err != nil || !ok { + t.Fatalf("GetActiveEnvelope: %v ok=%v", err, ok) + } + shares, err := skill.store.ListShares(ctx, envelopeTestChatID, env.ID) + if err != nil { + t.Fatalf("ListShares: %v", err) + } + rates, err := skill.store.GetExchangeRates(ctx) + if err != nil { + t.Fatalf("rates: %v", err) + } + target, okShare := shareRemainingForTest(shares, "Еда") + if !okShare { + t.Skip("в раскладке нет доли, владеющей «Еда» — сценарий не воспроизводим на этих данных") + } + + // Мелкая трата в пределах лимита — предупреждения быть не должно. + small := addExpense(100) + t.Logf("ответ бота (в пределах лимита):\n%s", small) + if strings.Contains(small, "пробит") { + t.Errorf("предупреждение на трате в пределах лимита:\n%s", small) + } + + // Трата, заведомо уводящая долю в минус: весь остаток доли + 5000 ₽. + overBy := 5000.0 + big := int(target.Allocated+target.CarriedIn)*int(rates["THB"]) + int(overBy) + huge := addExpense(big) + t.Logf("ответ бота (после пробоя):\n%s", huge) + if !strings.Contains(huge, "пробит") { + t.Fatalf("нет предупреждения о пробое:\n%s", huge) + } + if !strings.Contains(huge, target.Name) { + t.Errorf("в предупреждении нет имени доли %q:\n%s", target.Name, huge) + } + + // Сбой расчёта: ответ прежний, трата всё равно в базе. + skill.shareStore = &fakeShareStore{err: errors.New("боль в БД")} + plain := addExpense(100) + t.Logf("ответ бота (сбой расчёта):\n%s", plain) + if strings.Contains(plain, "пробит") { + t.Errorf("при сбое расчёта появилось предупреждение:\n%s", plain) + } + if len(created) != 3 { + t.Fatalf("записаны не все траты: %d из 3", len(created)) + } +} + +// shareRemainingForTest — доля, которой принадлежит категория, для расчёта +// суммы «заведомо больше лимита». +func shareRemainingForTest(shares []budget.EnvelopeShare, category string) (budget.EnvelopeShare, bool) { + sh := budget.ResolveShare(shares, nil, category) + if sh == nil { + return budget.EnvelopeShare{}, false + } + return *sh, true +} + +func itoa(v int) string { return strconv.Itoa(v) } + +// TestNewBudgetSkill_NilStore_NoPanic — NewBudgetSkill(nil) (так делает +// evals/cmd/routing) не должен класть в интерфейсное поле typed nil: иначе +// guard `st == nil` не срабатывает и первый же вызов стора паникует. +func TestNewBudgetSkill_NilStore_NoPanic(t *testing.T) { + s := NewBudgetSkill(nil) + if s.shareStore != nil { + t.Fatalf("shareStore при nil-сторе обязан быть nil-интерфейсом, получили %#v", s.shareStore) + } + got := s.shareOverspendWarning(warnCtx(), budget.Transaction{CategoryName: "Еда"}, budgetInput{}) + if got != "" { + t.Fatalf("без стора предупреждения быть не может, получили %q", got) + } +} + +// TestShareWarning_NonVariableCategory_NoWarning — трата в НЕ-переменной +// категории («Переводы», «Кредит», «Аренда») исключена из факта доли +// (computeShareRemaining, ADR-008 §4), поэтому приписывать ей пробой +// доли-приёмника «прочее» нельзя: она в этот конверт ничего не вносит. +func TestShareWarning_NonVariableCategory_NoWarning(t *testing.T) { + for _, cat := range []string{"Переводы", "Кредит", "Долг", "Аренда", "Подписки"} { + t.Run(cat, func(t *testing.T) { + st := &fakeShareStore{ + env: warnEnvelope(), shares: warnShares(), rates: warnRates(), + // «прочее» уже пробита чужой переменной тратой. + spent: []budget.CategorySpentRow{{CategoryName: "Развлечения", Currency: "RUB", Amount: 3000}}, + } + got := warnSkill(st).shareOverspendWarning(warnCtx(), budget.Transaction{CategoryName: cat}, budgetInput{}) + if got != "" { + t.Fatalf("трата в категории %q не входит ни в один конверт, а предупреждение есть: %q", cat, got) + } + }) + } +} + +// TestShareWarning_RecurringTransaction_NoWarning — транзакция с recurring_id +// вычтена как обязательство и не попадает в факт доли (ADR-008 §5), +// значит и предупреждения о пробое порождать не может. +func TestShareWarning_RecurringTransaction_NoWarning(t *testing.T) { + st := &fakeShareStore{ + env: warnEnvelope(), shares: warnShares(), rates: warnRates(), + spent: []budget.CategorySpentRow{{CategoryName: "Еда", Currency: "RUB", Amount: 12000}}, + } + rec := uuid.New() + got := warnSkill(st).shareOverspendWarning(warnCtx(), + budget.Transaction{CategoryName: "Еда", RecurringID: &rec}, budgetInput{}) + if got != "" { + t.Fatalf("recurring-трата не входит в факт доли, а предупреждение есть: %q", got) + } +} diff --git a/internal/skills/budget/skill.go b/internal/skills/budget/skill.go index bd94ec1..1b9eb31 100644 --- a/internal/skills/budget/skill.go +++ b/internal/skills/budget/skill.go @@ -14,11 +14,23 @@ import ( type BudgetSkill struct { store *budget.Store buckets BucketConfig + // shareStore — тот же store, но суженный до чтения конверта и долей. + // Отдельным полем, чтобы тест мог подменить его падающим стором и доказать, + // что сбой расчёта не ломает запись траты (ADR-008, задача 7/8). + shareStore shareWarningStore } // NewBudgetSkill создаёт BudgetSkill с дефолтной конфигурацией корзин. func NewBudgetSkill(store *budget.Store) *BudgetSkill { - return &BudgetSkill{store: store, buckets: defaultBuckets()} + s := &BudgetSkill{store: store, buckets: defaultBuckets()} + // Присваивать интерфейсное поле безусловно нельзя: nil-указатель, положенный + // в интерфейс, даёт typed nil — сравнение `shareStore == nil` становится + // false, и guard в shareOverspendWarning пропускает вызов по nil-стору + // (паника вместо тихого пропуска). Так вызывают evals/cmd/routing. + if store != nil { + s.shareStore = store + } + return s } // WithBuckets заменяет конфигурацию корзин. Возвращает ошибку если config невалиден. @@ -42,7 +54,12 @@ func (s *BudgetSkill) Manifest() plugin.Manifest { "Do NOT use for purchase advice / affordability questions / planning a future purchase ('планирую купить', 'хочу купить', 'стоит ли', 'можем ли позволить', 'хватит ли денег') — use the advisor skill for those. " + "Do NOT use for free-form spending analysis / anomalies / trends / savings advice ('проанализируй траты', 'найди аномалии', 'обзор трат', 'дай советы по экономии') — use advisor.analyze. " + "action='add_planned_expense' records a FUTURE planned one-off expense the user wants to set aside ('запланируй трату X на Y', 'будет трата X', 'отложи X на Z', 'плановая трата X') — it is NOT a completed transaction. " + - "action='start_envelope' SAVES an arrived income to track its remaining balance over time ('запомни приход X', 'создай конверт на X', 'заведи конверт X на 2 недели', 'начни отслеживать приход X'). " + + "action='start_envelope' SAVES an arrived income and SPLITS it into category envelopes ('запомни приход X', 'создай конверт на X', 'заведи конверт X на 2 недели', 'начни отслеживать приход X', " + + "'пришло X, разложи по конвертам', 'разложи приход X по конвертам', 'раскидай X по конвертам', 'пришло X, разложи'). " + + "Any 'разложи / раскидай / распредели ... по конвертам' with an arrived income is start_envelope — it WRITES the envelope and its shares; safe_to_spend only counts and writes nothing. " + + "action='set_share_limit' CORRECTS the LIMIT of a category envelope by hand ('на еду хватит 15000', 'на транспорт закладывай 5000', 'лимит на развлечения 3000', 'ставь на еду 15000') — pass name=<категория>, amount, currency. " + + "It is NOT a transaction: nothing was spent, the user is fixing the PLAN. The correction is remembered and applied to every following income until removed. " + + "action='clear_share_limit' REMOVES that manual limit ('убери лимит на еду', 'сними лимит с транспорта', 'считай лимит на еду сам') — the limit goes back to being computed from spending history; pass name=<категория>. " + "Use budget.summary for plain numerical totals only.", Version: "1.0.0", InputSchema: &plugin.Schema{ @@ -53,7 +70,7 @@ func (s *BudgetSkill) Manifest() plugin.Manifest { "properties": map[string]any{ "action": map[string]any{ "type": "string", - "description": "Action to perform: add_expense, add_income, summary, list_transactions, edit_transaction, add_goal, update_goal, goal_status, add_debt, pay_debt, debt_status, set_reminder, get_reminder, add_recurring, list_recurring, disable_recurring, forecast, add_planned_expense, start_envelope", + "description": "Action to perform: add_expense, add_income, summary, list_transactions, edit_transaction, add_goal, update_goal, goal_status, add_debt, pay_debt, debt_status, set_reminder, get_reminder, add_recurring, list_recurring, disable_recurring, forecast, add_planned_expense, start_envelope, set_share_limit, clear_share_limit", }, "amount": map[string]any{ "type": "number", @@ -73,7 +90,7 @@ func (s *BudgetSkill) Manifest() plugin.Manifest { }, "name": map[string]any{ "type": "string", - "description": "Name of a savings goal, debt, or recurring payment", + "description": "Name of a savings goal, debt, or recurring payment. For set_share_limit / clear_share_limit — the name of the category envelope whose limit is corrected ('еда', 'транспорт', 'развлечения').", }, "target_amount": map[string]any{ "type": "number", @@ -109,7 +126,11 @@ func (s *BudgetSkill) Manifest() plugin.Manifest { }, "currency": map[string]any{ "type": "string", - "description": "Transaction currency: RUB (default), USD, EUR, THB, etc. (ISO 4217)", + "description": "Currency of the AMOUNT in the message: RUB (default), USD, EUR, THB, etc. (ISO 4217). For set_share_limit pass THB when the user names the limit in baht ('на еду хватит 5000 бат') and RUB when in roubles ('на еду хватит 15000 рублей').", + }, + "display_currency": map[string]any{ + "type": "string", + "description": "Currency to SHOW envelopes in: THB (default, 'в батах') or RUB ('разложи и покажи в рублях', 'покажи конверты в рублях'). Affects only the printed answer, never the stored amounts. Not the same as currency, which describes the amount in the message.", }, "transaction_id": map[string]any{ "type": "string", @@ -172,34 +193,37 @@ func (s *BudgetSkill) Manifest() plugin.Manifest { // budgetInput — входные данные от LLM. type budgetInput struct { - Action string `json:"action"` - Amount float64 `json:"amount,omitempty"` - Category string `json:"category,omitempty"` - Description string `json:"description,omitempty"` - Period string `json:"period,omitempty"` - Name string `json:"name,omitempty"` - TargetAmount float64 `json:"target_amount,omitempty"` - Deadline string `json:"deadline,omitempty"` - GoalID string `json:"goal_id,omitempty"` - DebtID string `json:"debt_id,omitempty"` - Total float64 `json:"total,omitempty"` - Monthly float64 `json:"monthly,omitempty"` - Counterparty string `json:"counterparty,omitempty"` - Direction string `json:"direction,omitempty"` - Currency string `json:"currency,omitempty"` - TransactionID string `json:"transaction_id,omitempty"` - Keyword string `json:"keyword,omitempty"` - Date string `json:"date,omitempty"` - DateFrom string `json:"date_from,omitempty"` - DateTo string `json:"date_to,omitempty"` - ReminderEnabled *bool `json:"reminder_enabled,omitempty"` - ReminderHour *int `json:"reminder_hour,omitempty"` - ReminderMinute *int `json:"reminder_minute,omitempty"` - ReminderTimezone string `json:"reminder_timezone,omitempty"` - RecurringID string `json:"recurring_id,omitempty"` - DayOfMonth *int `json:"day_of_month,omitempty"` - TransactionType string `json:"transaction_type,omitempty"` - Months int `json:"months,omitempty"` + Action string `json:"action"` + Amount float64 `json:"amount,omitempty"` + Category string `json:"category,omitempty"` + Description string `json:"description,omitempty"` + Period string `json:"period,omitempty"` + Name string `json:"name,omitempty"` + TargetAmount float64 `json:"target_amount,omitempty"` + Deadline string `json:"deadline,omitempty"` + GoalID string `json:"goal_id,omitempty"` + DebtID string `json:"debt_id,omitempty"` + Total float64 `json:"total,omitempty"` + Monthly float64 `json:"monthly,omitempty"` + Counterparty string `json:"counterparty,omitempty"` + Direction string `json:"direction,omitempty"` + Currency string `json:"currency,omitempty"` + // DisplayCurrency — валюта ПОКАЗА конвертов (THB по умолчанию). Хранение + // долей не трогает: allocated/carried_in всегда в THB (ADR-008 §7). + DisplayCurrency string `json:"display_currency,omitempty"` + TransactionID string `json:"transaction_id,omitempty"` + Keyword string `json:"keyword,omitempty"` + Date string `json:"date,omitempty"` + DateFrom string `json:"date_from,omitempty"` + DateTo string `json:"date_to,omitempty"` + ReminderEnabled *bool `json:"reminder_enabled,omitempty"` + ReminderHour *int `json:"reminder_hour,omitempty"` + ReminderMinute *int `json:"reminder_minute,omitempty"` + ReminderTimezone string `json:"reminder_timezone,omitempty"` + RecurringID string `json:"recurring_id,omitempty"` + DayOfMonth *int `json:"day_of_month,omitempty"` + TransactionType string `json:"transaction_type,omitempty"` + Months int `json:"months,omitempty"` } // Run выполняет действие и возвращает текстовый ответ. @@ -257,6 +281,10 @@ func (s *BudgetSkill) Run(ctx context.Context, input string) (string, error) { return s.addPlannedExpense(ctx, req) case "start_envelope": return s.startEnvelope(ctx, req) + case "set_share_limit": + return s.setShareLimit(ctx, req) + case "clear_share_limit": + return s.clearShareLimit(ctx, req) default: return "", fmt.Errorf("unknown action: %s", req.Action) } diff --git a/internal/skills/budget/transactions.go b/internal/skills/budget/transactions.go index c61bb76..59c3abd 100644 --- a/internal/skills/budget/transactions.go +++ b/internal/skills/budget/transactions.go @@ -52,7 +52,13 @@ func (s *BudgetSkill) addTransaction(ctx context.Context, req budgetInput, typ s return "", fmt.Errorf("add transaction: %w", err) } - return formatTransaction(t, typ), nil + reply := formatTransaction(t, typ) + // Предупреждение о пробитом конверте — ПОВЕРХ обычного ответа и только для + // расхода: транзакция уже записана и не откатывается (ADR-008, задача 7/8). + if typ == "expense" { + reply += s.shareOverspendWarning(ctx, t, req) + } + return reply, nil } func (s *BudgetSkill) editTransaction(ctx context.Context, req budgetInput) (string, error) { diff --git a/internal/skills/safetospend/allocate.go b/internal/skills/safetospend/allocate.go new file mode 100644 index 0000000..e117826 --- /dev/null +++ b/internal/skills/safetospend/allocate.go @@ -0,0 +1,565 @@ +package safetospend + +import ( + "fmt" + "math" + "sort" + "strings" + "time" + + "simpleAI/internal/budget" +) + +// Раскладка прихода по категорийным конвертам (ADR-008). Чистые функции: ни +// БД, ни LLM — числа не проходят через модель (инвариант ADR-007 §4). + +// shareDraft — доля в процессе расчёта: имя ещё в отображаемом регистре, сумма +// ещё не округлена и может быть урезана. В budget.EnvelopeShare превращается +// только на последнем шаге, когда порядок и суммы окончательны. +type shareDraft struct { + name string // отображаемый регистр («Еда») + amount float64 // THB + source string // budget.ShareSourceAuto | budget.ShareSourceOverride + categories []string // нормализованные имена категорий доли +} + +// normalizeShareName — ключ сопоставления долей, категорий и override'ов. +// Своей нормализации не имеет: делегирует budget.NormalizeName, которым стор +// пишет и читает те же ключи. Ключ раскладки и ключ стора обязаны совпадать — +// собственная копия правила развела бы их на первой же правке. +func normalizeShareName(s string) string { + return budget.NormalizeName(s) +} + +// roundKopecks — округление суммы доли до копейки. +func roundKopecks(v float64) float64 { + return math.Round(v*kopecksInUnit) / kopecksInUnit +} + +// EnvelopePlanInput — вход раскладки прихода по конвертам. Всё, что читается из +// БД, собирает вызывающий (budget-скилл); здесь только счёт. +type EnvelopePlanInput struct { + IncomeTHB float64 // приход, сконвертированный в THB + Snapshot *budget.AdvisorSnapshot // обязательства за горизонт конверта + PlannedTHB float64 // ручные плановые траты + Forecast []budget.CategoryForecast // месячный прогноз по категориям + Rates map[string]float64 + Days int // длина горизонта конверта + Overrides map[string]float64 // ручные лимиты, THB, ключ нормализован + History map[string]int // категория → полных месяцев данных + // Recurring — ВСЕ регулярные платежи чата. Каждый попавший в окно + // финансирования становится отдельным видимым конвертом kind='fixed'. + // Заменяет собой Snapshot.UpcomingRecurring: сводная сумма обязательств и + // пофамильные конверты — одни и те же деньги, и складывать их значит + // вычесть обязательства дважды. + Recurring []budget.RecurringPayment + // From — начало периода конверта; от него отсчитывается окно + // финансирования регулярных платежей. + From time.Time +} + +// EnvelopePlan — результат раскладки: детерминированные числа и доли. +// Инвариант ADR-008 §4: Σ Shares[i].Allocated = Result.FreeAfterObligations. +type EnvelopePlan struct { + Result Result + Shares []budget.EnvelopeShare + Warnings []string +} + +// PlanEnvelope — единственная точка входа раскладки для внешних пакетов. +// Экспортируется целиком, а не по кусочкам (computeSafeToSpend + allocateShares +// отдельно): порядок «сначала обязательства, потом делим ОСТАТОК» — инвариант +// ADR-008 §3, и разрешать вызывающему собрать его самому значит разрешить +// разложить сырой приход. +func PlanEnvelope(in EnvelopePlanInput) EnvelopePlan { + snap := in.Snapshot + if snap == nil { + snap = &budget.AdvisorSnapshot{} + } + fixed, warnings := fixedShares(in.Recurring, in.Rates, in.From) + + // Обязательства снимаются с прихода РОВНО ОДИН раз — суммой пофамильных + // fixed-конвертов. Snapshot.UpcomingRecurring здесь подменяется ею, а не + // складывается: это одни и те же платежи, посчитанные с разными границами + // (снимок режет период конверта, окно финансирования — месяц вперёд). + obligations := *snap + obligations.UpcomingRecurring = sumAllocatedShares(fixed) + + _, forecastTHB := buildForecastBreakdown(in.Forecast, in.Rates, in.Days) + res := computeSafeToSpend(in.IncomeTHB, &obligations, in.PlannedTHB, forecastTHB) + + flexible, allocWarn := allocateShares( + res.FreeAfterObligations, in.Forecast, in.Rates, in.Days, in.Overrides, in.History) + warnings = append(warnings, allocWarn...) + + shares := make([]budget.EnvelopeShare, 0, len(fixed)+len(flexible)) + shares = append(shares, fixed...) + shares = append(shares, flexible...) + for i := range shares { + shares[i].Position = i + } + return EnvelopePlan{Result: res, Shares: shares, Warnings: warnings} +} + +// fixedShares превращает регулярные платежи в видимые конверты (simpleAI-faeq.11). +// +// Почему платежи стали конвертами, а не остались скрытым вычетом: оператор +// отверг деление трат на «обязательные» и «на жизнь» — «есть мне тоже надо, или +// ты считаешь что еда необязательна?». Ресёрч конвертного бюджетирования на его +// стороне: у YNAB обязательные платежи — первые КАТЕГОРИИ плана, финансируемые +// первыми, а не вычет до раскладки. Скрытый вычет ещё и ломает сходимость: в +// ответе бота приход не сходился визуально, 12 332 ฿ исчезали без строки. +// +// Окно финансирования — месяц вперёд от начала периода, а НЕ период конверта. +// Аренда платится 10.09, период кончается 06.09, но отложить деньги надо сейчас: +// иначе приход между 06.09 и 10.09 придётся на пустой карман (sinking fund). +// +// Категорий у fixed-доли нет: её факт — сам recurring-платёж, а транзакции с +// recurring_id в факт долей не попадают (ADR-008 §5). Дай мы ей категорию, +// платёж посчитался бы и обязательством, и тратой конверта. +// +// Порядок — по убыванию суммы: колонка чисел читается сверху вниз, и крупное +// обязательство должно быть первым (ADR-008 §11 — числа считает Go, не LLM). +func fixedShares(rec []budget.RecurringPayment, rates map[string]float64, from time.Time) ([]budget.EnvelopeShare, []string) { + if len(rec) == 0 { + return nil, nil + } + windowEnd := from.AddDate(0, 0, fixedFundingDays) + var warnings []string + out := make([]budget.EnvelopeShare, 0, len(rec)) + for _, r := range rec { + if !r.Enabled || r.Type != "expense" { + continue + } + if r.NextDate.Before(dayStart(from)) || !r.NextDate.Before(windowEnd) { + continue + } + thb, ok := budget.ToTHB(r.Amount, r.Currency, rates) + if !ok { + // Курса нет — выдумывать сумму нельзя, но и молчать нельзя: платёж + // исчезнет из плана, а деньги на него всё равно уйдут. + warnings = append(warnings, fmt.Sprintf("«%s» — нет курса %s, платёж не заложен", r.Name, r.Currency)) + continue + } + due := r.NextDate + out = append(out, budget.EnvelopeShare{ + Name: r.Name, + Kind: budget.ShareKindFixed, + Allocated: roundKopecks(thb), + Source: budget.ShareSourceAuto, + DueDate: &due, + }) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Allocated != out[j].Allocated { + return out[i].Allocated > out[j].Allocated + } + return out[i].Name < out[j].Name + }) + return out, warnings +} + +// dayStart — начало суток: next_date хранится DATE (полночь UTC), а from +// приходит с временем. Без обрезки платёж «сегодня» отсекался бы как прошлый. +func dayStart(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} + +func sumAllocatedShares(shares []budget.EnvelopeShare) float64 { + var total float64 + for _, sh := range shares { + total += sh.Allocated + } + return total +} + +// FixedShares — конверты под регулярные платежи: заперты, тратить нельзя. +func FixedShares(shares []budget.EnvelopeShare) []budget.EnvelopeShare { + return filterShares(shares, budget.ShareKindFixed) +} + +// DailyLimit — сколько можно тратить в день: гибкие конверты, делённые на +// оставшиеся дни (simpleAI-faeq.11 §5). +// +// Приход в формуле НЕ участвует. Это не украшение: лимит обязан работать и при +// приходе в 10 рублей, и при нулевом — конверты уже наполнены прошлым приходом, +// и вопрос «сколько можно сегодня» от факта нового прихода не зависит. +// +// В числитель входят только гибкие доли (kind='spend'): fixed заперты под +// конкретный платёж, save — накопления. Тратить из них «в день» нельзя. +// +// Пересчитывается при каждом показе от ОСТАТКА долей, поэтому потраченные в +// первый день 2000 ฿ автоматически опускают планку на остаток дней. Это +// зеркало, а не запрет. +func DailyLimit(flexibleTHB float64, daysLeft int) float64 { + if daysLeft < 1 { + daysLeft = 1 + } + if flexibleTHB <= 0 { + return 0 + } + return flexibleTHB / float64(daysLeft) +} + +// FlexibleTHB — числитель дневного лимита на момент раскладки: лимиты гибких +// долей вместе с перенесённым остатком. +func FlexibleTHB(shares []budget.EnvelopeShare) float64 { + var total float64 + for _, sh := range shares { + if sh.Kind == budget.ShareKindSpend { + total += sh.Allocated + sh.CarriedIn + } + } + return total +} + +// FlexibleRemainingTHB — тот же числитель, но по ОСТАТКУ долей: им считается +// дневной лимит после трат. Отдельная функция, а не флаг: у «сколько заложено» +// и «сколько осталось» разные источники, и путать их нельзя. +func FlexibleRemainingTHB(items []ShareRemaining) float64 { + var total float64 + for _, it := range items { + if it.Kind == budget.ShareKindSpend { + total += it.Remaining + } + } + return total +} + +// SpendShares / SaveShares — разрез раскладки для показа: лимиты трат и то, что +// осталось свободным (уходит в накопления). Разрез по Kind, а не по имени: +// имя доли-накопления оператор может поменять, вид — нет. +func SpendShares(shares []budget.EnvelopeShare) []budget.EnvelopeShare { + return filterShares(shares, budget.ShareKindSpend) +} + +func SaveShares(shares []budget.EnvelopeShare) []budget.EnvelopeShare { + return filterShares(shares, budget.ShareKindSave) +} + +func filterShares(shares []budget.EnvelopeShare, kind string) []budget.EnvelopeShare { + out := make([]budget.EnvelopeShare, 0, len(shares)) + for _, sh := range shares { + if sh.Kind == kind { + out = append(out, sh) + } + } + return out +} + +// allocateShares раскладывает free (Result.FreeAfterObligations, THB) по +// категорийным конвертам из истории трат. Чистая функция. +// +// Аргументы: +// - fc, rates, days — прогноз трат; базовые лимиты считает существующая +// buildForecastBreakdown (фикс-траты и движение денег уже отфильтрованы, +// месячный прогноз пропорционирован на длину периода); +// - overrides — ручные лимиты, ключ уже нормализован (Store.ListOverrides); +// - history — сколько ПОЛНЫХ месяцев данных есть по категории. +// +// Правила (ADR-008): +// 1. один конверт = одна категория; лимит меньше minShareMonthlyTHB (в пересчёте +// на длину периода) сливается в «прочее»; +// 2. категория с историей меньше minHistoryMonths месяцев собственного лимита +// НЕ получает вовсе (сумму не выдумываем) — сама категория уходит в +// «прочее», чтобы трате было куда падать, плюс warning. Из этого следует: +// пустой history ⇒ ни одна категория не получит свой лимит, всё уйдёт в +// накопления. Вызывающий обязан передать реальную глубину истории; +// 3. «прочее» создаётся ВСЕГДА, даже с нулевым лимитом: budget.ResolveShare +// без fallback-доли возвращает nil, и трате в неизвестной категории +// становится некуда падать; +// 4. override заменяет авто-лимит и помечает долю ShareSourceOverride; +// 5. Σ лимитов > free → пропорциональное усечение авто-долей; override режется +// только если одних override'ов уже больше free; +// 6. непокрытый остаток уходит в долю «накопления» (ShareKindSave). +// +// Инвариант: Σ Allocated + свободно = free копейка в копейку. Держится тем, что +// доли трат округляются до копейки, а «накопления» берут ТОЧНЫЙ остаток +// free − Σ(остальные) без округления — накопленная ошибка округления оседает в +// них, а не размазывается по раскладке. +func allocateShares( + free float64, + fc []budget.CategoryForecast, + rates map[string]float64, + days int, + overrides map[string]float64, + history map[string]int, +) ([]budget.EnvelopeShare, []string) { + drafts, warnings := buildDrafts(free, fc, rates, days, history, nil) + drafts, warnings = applyOverrides(drafts, overrides, warnings) + warnings = truncateToFree(drafts, free, warnings) + + return finalizeShares(drafts, free), warnings +} + +// buildDrafts строит черновики долей из прогноза: одна категория — одна доля, +// мелочь и категории без истории — в «прочее». Последний черновик — всегда +// «прочее»: доля-приёмник обязана существовать даже с нулевым лимитом. +func buildDrafts( + free float64, + fc []budget.CategoryForecast, + rates map[string]float64, + days int, + history map[string]int, + warnings []string, +) ([]*shareDraft, []string) { + items, _ := buildForecastBreakdown(fc, rates, days) + // Планка мелочи — абсолютная, пропорционально длине периода. От free она + // зависеть не имеет права: см. minShareMonthlyTHB (simpleAI-faeq.10, баг 3). + threshold := minShareMonthlyTHB * float64(days) / prorationBaseDays + + fallback := &shareDraft{name: budget.FallbackShareName, source: budget.ShareSourceAuto} + drafts := make([]*shareDraft, 0, len(items)+1) + var lowData []string + + for _, it := range items { + norm := normalizeShareName(it.Category) + if historyMonths(history, it.Category) < minHistoryMonths { + // Лимит не выдумываем: сумма по одному месяцу — не статистика. + lowData = append(lowData, it.Category) + fallback.categories = append(fallback.categories, norm) + continue + } + if norm == budget.FallbackShareName || it.THB < threshold { + // Мелочь сливаем в «прочее» вместе с её лимитом. Категория, + // БУКВАЛЬНО названная «прочее», — туда же: отдельным черновиком она + // стала бы вторым «прочее», и на сборке (finalizeShares) один из двух + // затёр бы другой, а его лимит остался бы в Σ allocated и утёк из + // раскладки — инвариант ADR-008 §4 не сошёлся бы. + fallback.amount += it.THB + fallback.categories = append(fallback.categories, norm) + continue + } + if d := findDraft(drafts, norm); d != nil { + // Регистровые дубли категорий («Еда» и «еда» — разные строки в + // budget_category с разными id, ADR-008 §6) дают два черновика с + // одним именем доли. Складываем, а не добавляем второй: две доли с + // одним именем ломают и сборку, и UNIQUE(envelope_id,name). + d.amount += it.THB + continue + } + drafts = append(drafts, &shareDraft{ + name: it.Category, + amount: it.THB, + source: budget.ShareSourceAuto, + categories: []string{norm}, + }) + } + + return append(drafts, fallback), append(warnings, lowDataWarning(lowData)...) +} + +// lowDataWarning сворачивает «мало данных» в ОДНУ строку. +// +// По строке на категорию было ровно тем антипаттерном, который ресёрч +// конвертного бюджетирования называет главной причиной развала метода: живой +// прогон дал одиннадцать предупреждений подряд — «ресторан», «корм», «связь», +// «цветы», «посуда»… Одиннадцать строк шума прячут сам ответ, а действие у +// оператора на все них одно: подождать, пока накопится статистика, либо +// назначить лимит словами. Список обрезается — читать хвост из мелких +// категорий человек всё равно не станет. +func lowDataWarning(cats []string) []string { + if len(cats) == 0 { + return nil + } + shown := cats + tail := "" + if len(shown) > lowDataNamesShown { + tail = fmt.Sprintf(" и ещё %d", len(shown)-lowDataNamesShown) + shown = shown[:lowDataNamesShown] + } + return []string{fmt.Sprintf( + "Мало истории: %s%s — пока считаю их в конверте «%s». Скажи «на %s хватит N», если нужен свой лимит.", + strings.Join(shown, ", "), tail, budget.FallbackShareName, cats[0])} +} + +// historyMonths — глубина истории по категории. Ключ ищется в нормализованном +// виде и в исходном: вызывающие собирают карту по-разному, а цена промаха — +// молча потерянный лимит. +func historyMonths(history map[string]int, category string) int { + if m, ok := history[normalizeShareName(category)]; ok { + return m + } + return history[category] +} + +// applyOverrides накладывает ручные лимиты поверх авто-раскладки. Override на +// имя, которого в раскладке нет, заводит новую долю: оператор мог назвать +// категорию, по которой прогноза ещё нет. +func applyOverrides(drafts []*shareDraft, overrides map[string]float64, warnings []string) ([]*shareDraft, []string) { + names := make([]string, 0, len(overrides)) + for name := range overrides { + names = append(names, name) + } + sort.Strings(names) // порядок обхода карты недетерминирован, а он влияет на раскладку + + for _, name := range names { + amount := overrides[name] + norm := normalizeShareName(name) + if norm == savingsShareName { + // «Накопления» — не лимит, а непокрытый остаток; задать его руками нельзя. + warnings = append(warnings, fmt.Sprintf("«%s» считаются как остаток — ручной лимит не применяется", savingsShareName)) + continue + } + if amount < 0 { + warnings = append(warnings, fmt.Sprintf("ручной лимит по «%s» отрицательный — игнорируем", name)) + continue + } + if d := findDraft(drafts, norm); d != nil { + d.amount = amount + d.source = budget.ShareSourceOverride + continue + } + drafts = append(drafts, &shareDraft{ + name: name, + amount: amount, + source: budget.ShareSourceOverride, + categories: []string{norm}, + }) + } + return drafts, warnings +} + +func findDraft(drafts []*shareDraft, norm string) *shareDraft { + for _, d := range drafts { + if normalizeShareName(d.name) == norm { + return d + } + } + return nil +} + +// truncateToFree ужимает лимиты в свободную сумму. Режутся авто-доли; override +// трогаем только если одних override'ов уже больше free — иначе ручное решение +// оператора молча переписывалось бы статистикой. +func truncateToFree(drafts []*shareDraft, free float64, warnings []string) []string { + if free <= 0 { + // Раскладывать нечего: обнуляем всё, доли остаются как маршруты трат. + for _, d := range drafts { + d.amount = 0 + } + return append(warnings, "свободных денег нет — лимиты не назначены") + } + + var sumAuto, sumOverride float64 + for _, d := range drafts { + if d.source == budget.ShareSourceOverride { + sumOverride += d.amount + } else { + sumAuto += d.amount + } + } + if sumAuto+sumOverride <= free { + return warnings + } + + if sumOverride > free { + k := free / sumOverride + for _, d := range drafts { + if d.source == budget.ShareSourceOverride { + d.amount *= k + } else { + d.amount = 0 + } + } + return append(warnings, fmt.Sprintf( + "ручные лимиты (%.0f) больше свободных %.0f — урезаны пропорционально", sumOverride, free)) + } + + var k float64 + if sumAuto > 0 { + k = (free - sumOverride) / sumAuto + } + for _, d := range drafts { + if d.source != budget.ShareSourceOverride { + d.amount *= k + } + } + return append(warnings, fmt.Sprintf( + "лимиты (%.0f) не помещаются в свободные %.0f — авто-лимиты урезаны пропорционально", sumAuto+sumOverride, free)) +} + +// finalizeShares округляет суммы, добавляет «накопления» на непокрытый остаток +// и раскладывает доли по позициям: траты по убыванию лимита, затем «прочее», +// последними — «накопления». +func finalizeShares(drafts []*shareDraft, free float64) []budget.EnvelopeShare { + var allocated float64 + for _, d := range drafts { + d.amount = roundKopecks(d.amount) + allocated += d.amount + } + // Округление вверх могло выбить сумму за free на несколько копеек — снимаем + // их с самой крупной доли, иначе «накопления» ушли бы в минус. Границу + // держим на копейке с доли: перебор больше этого — не ошибка округления, а + // несработавшее усечение, и заминать его здесь нельзя (иначе сумма сойдётся, + // а раскладка будет врать). + if excess := allocated - free; free > 0 && excess > 0 && excess <= float64(len(drafts))/kopecksInUnit { + if d := largestDraft(drafts); d != nil { + d.amount = roundKopecks(d.amount - excess) + allocated = 0 + for _, d := range drafts { + allocated += d.amount + } + } + } + + savings := free - allocated + if free <= 0 || savings < 0 { + savings = 0 + } + + spend := make([]*shareDraft, 0, len(drafts)) + var fallback *shareDraft + for _, d := range drafts { + if normalizeShareName(d.name) == budget.FallbackShareName { + fallback = d + continue + } + spend = append(spend, d) + } + sort.SliceStable(spend, func(i, j int) bool { + if spend[i].amount != spend[j].amount { + return spend[i].amount > spend[j].amount + } + return spend[i].name < spend[j].name + }) + if fallback != nil { + spend = append(spend, fallback) + } + spend = append(spend, &shareDraft{ + name: savingsShareName, + amount: savings, + source: budget.ShareSourceAuto, + }) + + shares := make([]budget.EnvelopeShare, 0, len(spend)) + for i, d := range spend { + kind := budget.ShareKindSpend + if d.name == savingsShareName { + kind = budget.ShareKindSave + } + cats := make([]budget.EnvelopeShareCategory, 0, len(d.categories)) + for _, c := range d.categories { + cats = append(cats, budget.EnvelopeShareCategory{CategoryName: c}) + } + shares = append(shares, budget.EnvelopeShare{ + Name: d.name, + Kind: kind, + Allocated: d.amount, + Source: d.source, + Position: i, + Categories: cats, + }) + } + return shares +} + +func largestDraft(drafts []*shareDraft) *shareDraft { + var best *shareDraft + for _, d := range drafts { + if best == nil || d.amount > best.amount { + best = d + } + } + return best +} diff --git a/internal/skills/safetospend/allocate_test.go b/internal/skills/safetospend/allocate_test.go new file mode 100644 index 0000000..a6cea9e --- /dev/null +++ b/internal/skills/safetospend/allocate_test.go @@ -0,0 +1,449 @@ +package safetospend + +import ( + "math" + "strings" + "testing" + + "simpleAI/internal/budget" +) + +// oneToOne — курсы 1:1, чтобы THB читались как рубли эталона и суммы сверялись +// напрямую (тот же приём, что в compute_test.go). +var oneToOne = map[string]float64{"RUB": 1.0, "THB": 1.0} + +func fcRUB(name string, amount float64) budget.CategoryForecast { + return budget.CategoryForecast{CategoryName: name, Currency: "RUB", ForecastAmount: amount} +} + +func sumAllocated(shares []budget.EnvelopeShare) float64 { + var s float64 + for _, sh := range shares { + s += sh.Allocated + } + return s +} + +func allocatedByName(shares []budget.EnvelopeShare) map[string]float64 { + m := make(map[string]float64, len(shares)) + for _, sh := range shares { + m[sh.Name] = sh.Allocated + } + return m +} + +// checkConvergence — главный инвариант ADR-008: Σ Allocated + свободно = free, +// копейка в копейку. «Свободно» — непокрытая часть прихода: при free > 0 её +// забирают «накопления», поэтому Σ Allocated обязана сойтись ровно в free; +// при free <= 0 раскладывать нечего, Σ Allocated = 0, а «свободно» = free. +// Инвариант формулируется ТОЛЬКО на Allocated: RemainingTHB из compute.go — +// другая величина (там три слагаемых), сцеплять их нельзя. +func checkConvergence(t *testing.T, shares []budget.EnvelopeShare, free float64) { + t.Helper() + allocated := sumAllocated(shares) + unallocated := free - allocated // «свободно»: то, что не попало ни в одну долю + + wantAllocated, wantUnallocated := free, 0.0 + if free <= 0 { + wantAllocated, wantUnallocated = 0, free + } + if math.Abs(allocated-wantAllocated) > 0.005 { + t.Errorf("Σ Allocated: want %.4f, got %.4f (расхождение %.4f)", wantAllocated, allocated, allocated-wantAllocated) + } + if math.Abs(unallocated-wantUnallocated) > 0.005 { + t.Errorf("свободно: want %.4f, got %.4f", wantUnallocated, unallocated) + } +} + +func hasWarning(warnings []string, substr string) bool { + for _, w := range warnings { + if strings.Contains(w, substr) { + return true + } + } + return false +} + +func TestAllocateShares(t *testing.T) { + cases := []struct { + name string + free float64 + fc []budget.CategoryForecast + overrides map[string]float64 + history map[string]int + want map[string]float64 // имя доли → лимит (проверяются только перечисленные) + wantWarn string + wantSrc map[string]string + }{ + { + name: "обычная раскладка: мелочь в «прочее», остаток в накопления", + free: 27800, + fc: []budget.CategoryForecast{ + fcRUB("Еда", 12000), fcRUB("Транспорт", 4000), + fcRUB("Развлечения", 3000), fcRUB("Кафе", 400), // 400 < minShareMonthlyTHB=500 → в «прочее» + fcRUB("Жильё", 50000), fcRUB("Переводы", 90000), // фикс и движение денег — вне раскладки + }, + history: map[string]int{"еда": 3, "транспорт": 3, "развлечения": 3, "кафе": 3}, + want: map[string]float64{ + "Еда": 12000, "Транспорт": 4000, "Развлечения": 3000, + budget.FallbackShareName: 400, savingsShareName: 8400, + }, + }, + { + name: "нехватка остатка: авто-лимиты урезаны пропорционально", + free: 10000, + fc: []budget.CategoryForecast{fcRUB("Еда", 12000), fcRUB("Транспорт", 4000)}, + history: map[string]int{"еда": 3, "транспорт": 3}, + // k = 10000/16000 = 0.625 + want: map[string]float64{"Еда": 7500, "Транспорт": 2500, savingsShareName: 0}, + wantWarn: "не помещаются", + }, + { + name: "override поверх авто: заменяет лимит, соседей не двигает", + free: 27800, + fc: []budget.CategoryForecast{fcRUB("Еда", 12000), fcRUB("Транспорт", 4000)}, + overrides: map[string]float64{"еда": 15000}, + history: map[string]int{"еда": 3, "транспорт": 3}, + want: map[string]float64{"Еда": 15000, "Транспорт": 4000, savingsShareName: 8800}, + wantSrc: map[string]string{"Еда": budget.ShareSourceOverride, "Транспорт": budget.ShareSourceAuto}, + }, + { + name: "override больше free: режутся сами override, авто в ноль", + free: 10000, + fc: []budget.CategoryForecast{fcRUB("Еда", 12000), fcRUB("Транспорт", 4000), fcRUB("Развлечения", 3000)}, + overrides: map[string]float64{"еда": 9000, "транспорт": 6000}, + history: map[string]int{"еда": 3, "транспорт": 3, "развлечения": 3}, + // override 15000 > 10000 → k = 2/3; авто-доля «Развлечения» обнуляется + want: map[string]float64{"Еда": 6000, "Транспорт": 4000, "Развлечения": 0, savingsShareName: 0}, + wantWarn: "ручные лимиты", + }, + { + name: "мало истории: лимит не выдумываем, категория в «прочее»", + free: 10000, + fc: []budget.CategoryForecast{fcRUB("Еда", 6000), fcRUB("Одежда", 2000)}, + history: map[string]int{"еда": 3, "одежда": 1}, + want: map[string]float64{ + "Еда": 6000, budget.FallbackShareName: 0, savingsShareName: 4000, + }, + wantWarn: "Мало истории: Одежда", + }, + { + name: "нулевой free: лимитов нет", + free: 0, + fc: []budget.CategoryForecast{fcRUB("Еда", 6000)}, + history: map[string]int{"еда": 3}, + want: map[string]float64{"Еда": 0, budget.FallbackShareName: 0, savingsShareName: 0}, + wantWarn: "свободных денег нет", + }, + { + name: "отрицательный free: лимитов нет, в минус не уходим", + free: -5000, + fc: []budget.CategoryForecast{fcRUB("Еда", 6000)}, + history: map[string]int{"еда": 3}, + want: map[string]float64{"Еда": 0, savingsShareName: 0}, + wantWarn: "свободных денег нет", + }, + { + name: "только фиксированные категории: всё в накопления", + free: 10000, + fc: []budget.CategoryForecast{fcRUB("Жильё", 50000), fcRUB("Переводы", 90000), fcRUB("Подписки", 2000)}, + history: map[string]int{"жильё": 6, "переводы": 6, "подписки": 6}, + want: map[string]float64{budget.FallbackShareName: 0, savingsShareName: 10000}, + }, + { + name: "пустой прогноз: одна fallback-доля и накопления", + free: 5000, + fc: nil, + history: nil, + want: map[string]float64{budget.FallbackShareName: 0, savingsShareName: 5000}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + shares, warnings := allocateShares(tc.free, tc.fc, oneToOne, 30, tc.overrides, tc.history) + + checkConvergence(t, shares, tc.free) + + got := allocatedByName(shares) + for name, want := range tc.want { + val, ok := got[name] + if !ok { + t.Fatalf("доля %q отсутствует; получено %v", name, got) + } + if !approx(val, want) { + t.Errorf("доля %q: want %.2f, got %.2f", name, want, val) + } + } + if tc.wantWarn != "" && !hasWarning(warnings, tc.wantWarn) { + t.Errorf("ожидался warning со словами %q, получено %v", tc.wantWarn, warnings) + } + if tc.wantWarn == "" && len(warnings) > 0 { + t.Errorf("warnings не ожидались, получено %v", warnings) + } + for name, wantSrc := range tc.wantSrc { + for _, sh := range shares { + if sh.Name == name && sh.Source != wantSrc { + t.Errorf("доля %q: Source want %q, got %q", name, wantSrc, sh.Source) + } + } + } + }) + } +} + +// Fallback-доля обязана существовать всегда: без неё budget.ResolveShare вернёт +// nil и трате в неизвестной категории будет некуда падать (ADR-008). +func TestAllocateShares_FallbackAlwaysRoutesUnknownSpend(t *testing.T) { + for _, free := range []float64{27800, 0, -1000} { + shares, _ := allocateShares(free, []budget.CategoryForecast{fcRUB("Еда", 12000)}, + oneToOne, 30, nil, map[string]int{"еда": 3}) + + if budget.FallbackShare(shares) == nil { + t.Fatalf("free=%.0f: доля «%s» не создана", free, budget.FallbackShareName) + } + if sh := budget.ResolveShare(shares, nil, "Неизвестная категория"); sh == nil { + t.Errorf("free=%.0f: трата в неизвестной категории не маршрутизируется", free) + } + if sh := budget.ResolveShare(shares, nil, "Еда"); sh == nil || sh.Name != "Еда" { + t.Errorf("free=%.0f: трата «Еда» ушла не в свою долю: %+v", free, sh) + } + } +} + +// Виды и позиции: траты — spend, «накопления» — save и всегда последние. +func TestAllocateShares_KindsAndPositions(t *testing.T) { + shares, _ := allocateShares(27800, + []budget.CategoryForecast{fcRUB("Еда", 12000), fcRUB("Транспорт", 4000)}, + oneToOne, 30, nil, map[string]int{"еда": 3, "транспорт": 3}) + + if len(shares) != 4 { // Еда, Транспорт, прочее, накопления + t.Fatalf("ожидалось 4 доли, got %d: %+v", len(shares), shares) + } + for i, sh := range shares { + if sh.Position != i { + t.Errorf("доля %q: Position want %d, got %d", sh.Name, i, sh.Position) + } + } + if shares[0].Name != "Еда" || shares[1].Name != "Транспорт" { + t.Errorf("ожидалась сортировка по убыванию лимита, got %q, %q", shares[0].Name, shares[1].Name) + } + last := shares[len(shares)-1] + if last.Name != savingsShareName || last.Kind != budget.ShareKindSave { + t.Errorf("последняя доля должна быть «%s» с Kind=%s, got %q/%s", + savingsShareName, budget.ShareKindSave, last.Name, last.Kind) + } + for _, sh := range shares[:len(shares)-1] { + if sh.Kind != budget.ShareKindSpend { + t.Errorf("доля %q: Kind want %s, got %s", sh.Name, budget.ShareKindSpend, sh.Kind) + } + } + // Категории хранятся нормализованными (двойной ключ ADR-008). + if len(shares[0].Categories) != 1 || shares[0].Categories[0].CategoryName != "еда" { + t.Errorf("категории доли «Еда»: want [еда], got %+v", shares[0].Categories) + } +} + +// Копейки при пропорциональном усечении: суммы долей округляются до копейки, +// а накопленный остаток округления оседает в «накоплениях» — сумма обязана +// сойтись в free точно, а не «примерно». +func TestAllocateShares_KopecksOnTruncation(t *testing.T) { + const free = 10000.55 + shares, warnings := allocateShares(free, + []budget.CategoryForecast{fcRUB("Еда", 12345.67), fcRUB("Транспорт", 7654.33)}, + oneToOne, 30, nil, map[string]int{"еда": 3, "транспорт": 3}) + + if !hasWarning(warnings, "не помещаются") { + t.Errorf("ожидался warning об усечении, got %v", warnings) + } + got := sumAllocated(shares) + if math.Abs(got-free) > 0.005 { + t.Errorf("Σ Allocated want %.2f, got %.4f", free, got) + } + for _, sh := range shares { + if sh.Allocated < 0 { + t.Errorf("доля %q ушла в минус: %.4f", sh.Name, sh.Allocated) + } + if sh.Name == savingsShareName { + continue // остаток намеренно точный, не округлённый + } + if math.Abs(sh.Allocated*100-math.Round(sh.Allocated*100)) > 1e-6 { + t.Errorf("доля %q не округлена до копейки: %.6f", sh.Name, sh.Allocated) + } + } +} + +// Сходимость при усечении на сетке «некруглых» сумм: проверяет, что остаток +// округления не теряется и не уводит доли в минус ни на одной из комбинаций, +// а не только на одной удачно подобранной. Без компенсации копеек часть этих +// прогонов даёт Σ Allocated > free. +func TestAllocateShares_KopecksAcrossRange(t *testing.T) { + fc := []budget.CategoryForecast{ + fcRUB("Еда", 12345.67), fcRUB("Транспорт", 7654.33), + fcRUB("Развлечения", 3333.33), fcRUB("Кафе", 1111.11), + } + history := map[string]int{"еда": 3, "транспорт": 3, "развлечения": 3, "кафе": 3} + + for i := 0; i < 300; i++ { + free := 9000 + float64(i)*0.37 // некруглые суммы, усечение почти всегда срабатывает + shares, _ := allocateShares(free, fc, oneToOne, 30, nil, history) + + if got := sumAllocated(shares); math.Abs(got-free) > 0.005 { + t.Fatalf("free=%.2f: Σ Allocated %.4f, расхождение %.4f", free, got, got-free) + } + for _, sh := range shares { + if sh.Allocated < 0 { + t.Fatalf("free=%.2f: доля %q ушла в минус: %.4f", free, sh.Name, sh.Allocated) + } + } + } +} + +// TestAcceptance_Allocate_27800 — эталон ADR-007/ADR-008 в раскладке: приход +// 127000₽ минус обязательства 99200 = свободно 27800₽ (см. +// TestAcceptance_127k_to_27800), которые раскладываются по конвертам ровно. +// Единицы: THB==₽ (курс 1:1) для прямого сравнения. +func TestAcceptance_Allocate_27800(t *testing.T) { + snap := &budget.AdvisorSnapshot{UpcomingRecurring: 28000} + const planned = 36400 + 15600 + 6720 + 5200 + 3380 + 3900 // = 71200 + res := computeSafeToSpend(127000, snap, planned, 0) + if !approx(res.FreeAfterObligations, 27800) { + t.Fatalf("предпосылка эталона сломана: свободно want 27800, got %.2f", res.FreeAfterObligations) + } + + fc := []budget.CategoryForecast{ + fcRUB("Еда", 12000), fcRUB("Транспорт", 4000), fcRUB("Развлечения", 3000), + fcRUB("Кафе", 400), // < minShareMonthlyTHB=500 → в «прочее» + fcRUB("Одежда", 2000), // история 1 месяц → лимит не назначаем + fcRUB("Жильё", 50000), fcRUB("Переводы", 90000), + } + history := map[string]int{"еда": 3, "транспорт": 3, "развлечения": 3, "кафе": 3, "одежда": 1} + + shares, warnings := allocateShares(res.FreeAfterObligations, fc, oneToOne, 30, nil, history) + + checkConvergence(t, shares, res.FreeAfterObligations) + want := map[string]float64{ + "Еда": 12000, "Транспорт": 4000, "Развлечения": 3000, + budget.FallbackShareName: 400, savingsShareName: 8400, + } + got := allocatedByName(shares) + if len(got) != len(want) { + t.Fatalf("состав раскладки: want %v, got %v", want, got) + } + for name, w := range want { + if !approx(got[name], w) { + t.Errorf("доля %q: want %.2f, got %.2f", name, w, got[name]) + } + } + if !hasWarning(warnings, "Мало истории: Одежда") { + t.Errorf("ожидался warning про Одежду, got %v", warnings) + } +} + +// Override на «накопления» не применяется: они не лимит, а непокрытый остаток. +func TestAllocateShares_OverrideOnSavingsIgnored(t *testing.T) { + shares, warnings := allocateShares(10000, + []budget.CategoryForecast{fcRUB("Еда", 6000)}, oneToOne, 30, + map[string]float64{savingsShareName: 1000}, map[string]int{"еда": 3}) + + checkConvergence(t, shares, 10000) + if got := allocatedByName(shares)[savingsShareName]; !approx(got, 4000) { + t.Errorf("накопления: want 4000 (остаток), got %.2f", got) + } + if !hasWarning(warnings, "считаются как остаток") { + t.Errorf("ожидался warning про накопления, got %v", warnings) + } +} + +// Override на имя, которого в авто-раскладке нет, заводит новую долю: оператор +// мог назвать категорию, по которой прогноза ещё не было. +func TestAllocateShares_OverrideCreatesMissingShare(t *testing.T) { + shares, _ := allocateShares(10000, + []budget.CategoryForecast{fcRUB("Еда", 6000)}, oneToOne, 30, + map[string]float64{"спорт": 1500}, map[string]int{"еда": 3}) + + checkConvergence(t, shares, 10000) + var found *budget.EnvelopeShare + for i := range shares { + if shares[i].Name == "спорт" { + found = &shares[i] + } + } + if found == nil { + t.Fatalf("доля «спорт» не создана: %+v", allocatedByName(shares)) + } + if !approx(found.Allocated, 1500) || found.Source != budget.ShareSourceOverride { + t.Errorf("доля «спорт»: want 1500/override, got %.2f/%s", found.Allocated, found.Source) + } + if sh := budget.ResolveShare(shares, nil, "Спорт"); sh == nil || sh.Name != "спорт" { + t.Errorf("трата «Спорт» не маршрутизируется в свою долю: %+v", sh) + } +} + +// Раскладка детерминирована: одинаковый вход — одинаковый выход, включая +// порядок долей (обход карты override'ов отсортирован). +func TestAllocateShares_Deterministic(t *testing.T) { + fc := []budget.CategoryForecast{fcRUB("Еда", 6000), fcRUB("Транспорт", 3000)} + hist := map[string]int{"еда": 3, "транспорт": 3} + ovr := map[string]float64{"спорт": 1200, "хобби": 900, "книги": 700} + + first, _ := allocateShares(20000, fc, oneToOne, 30, ovr, hist) + for i := 0; i < 10; i++ { + next, _ := allocateShares(20000, fc, oneToOne, 30, ovr, hist) + if len(next) != len(first) { + t.Fatalf("разное число долей: %d vs %d", len(next), len(first)) + } + for j := range first { + if next[j].Name != first[j].Name || !approx(next[j].Allocated, first[j].Allocated) { + t.Fatalf("прогон %d, позиция %d: %q/%.2f vs %q/%.2f", + i, j, next[j].Name, next[j].Allocated, first[j].Name, first[j].Allocated) + } + } + } +} + +// TestAllocateShares_DuplicateNamesMerge — регрессия на молча теряющийся лимит. +// +// Два источника одинакового имени доли: (1) категория, БУКВАЛЬНО названная +// «Прочее» — совпадает с именем fallback-доли; (2) регистровые дубли категорий +// («Еда» и «еда» — по ADR-008 §6 это разные строки budget_category с разными +// id, и прогноз, сгруппированный по имени, даёт их обе). +// +// До фикса каждый из них создавал ВТОРУЮ долю с тем же именем: на сборке одна +// затирала другую, но её сумма оставалась в Σ allocated — «накопления» получали +// заниженный остаток, и Σ allocated + свободно ≠ free. Ловится инвариантом, а +// не сравнением текста: расхождение вылезло на живом прогоне (127000 ₽, реплика +// с категориями «Прочее» и «clothes»), а не в юнит-фикстуре. +func TestAllocateShares_DuplicateNamesMerge(t *testing.T) { + const free = 100000.0 + fc := []budget.CategoryForecast{ + fcRUB("Прочее", 20000), + fcRUB("Еда", 30000), + fcRUB("еда", 10000), + } + history := map[string]int{"прочее": 3, "еда": 3} + + shares, _ := allocateShares(free, fc, oneToOne, 30, nil, history) + + checkConvergence(t, shares, free) + + seen := map[string]int{} + for _, sh := range shares { + seen[normalizeShareName(sh.Name)]++ + } + for name, n := range seen { + if n > 1 { + t.Errorf("доля %q встречается %d раза — UNIQUE(envelope_id,name) отобьёт вторую", name, n) + } + } + + byName := allocatedByName(shares) + // Регистровые дубли складываются, а не теряются. + if got := byName["Еда"]; math.Abs(got-40000) > 0.01 { + t.Errorf("«Еда» = %.2f, ожидали 40000 (30000 + дубль 10000)", got) + } + // Категория «Прочее» уходит в долю-приёмник вместе со своим лимитом. + if got := byName[budget.FallbackShareName]; math.Abs(got-20000) > 0.01 { + t.Errorf("«%s» = %.2f, ожидали 20000 (лимит категории «Прочее»)", budget.FallbackShareName, got) + } +} diff --git a/internal/skills/safetospend/carry.go b/internal/skills/safetospend/carry.go new file mode 100644 index 0000000..cb67421 --- /dev/null +++ b/internal/skills/safetospend/carry.go @@ -0,0 +1,196 @@ +package safetospend + +import ( + "simpleAI/internal/budget" +) + +// Перенос накопленного между конвертами (ADR-008 §9). Чистая функция: остаток +// прошлого конверта считается ТЕМ ЖЕ computeShareRemaining, что и ответ «сколько +// осталось» — второй формулы остатка в проекте быть не должно, иначе перенос и +// показ разойдутся на глазах у оператора. + +// CarryInput — вход переноса. +// +// PrevSpent — сырой факт за период ЗАКРЫВАЕМОГО конверта (уже без recurring). +// Пустой факт допустим и означает «за конверт не потрачено ничего» — так же +// выглядит конверт нулевой длины, факт по которому считать нельзя (ADR-008 §10). +// +// PrevSuperseded — прошлый конверт закрывается в день своего создания, то есть +// не прожил ни одного дня и вытесняется повторной раскладкой. См. CarryOver. +type CarryInput struct { + PrevShares []budget.EnvelopeShare + PrevSpent []budget.CategorySpentRow + Rates map[string]float64 + NextShares []budget.EnvelopeShare + PrevSuperseded bool +} + +// CarryOver проставляет новым долям carried_in по остаткам старых. +// +// Правила (ADR-008 §9): +// - kind='save' — остаток переносится в одноимённую новую долю; +// - kind='spend' — остаток сгорает, carried_in = 0 ЯВНО (новая раскладка +// приходит из PlanEnvelope с нулями, но полагаться на это нельзя: доля, +// пришедшая откуда-то ещё с ненулевым carried_in, молча удвоила бы деньги); +// - save-доля, исчезнувшая из новой раскладки, создаётся принудительно с +// allocated=0 и carried_in=остаток — иначе накопленное пропало бы вместе с +// долей. +// +// Ключ переноса — нормализованное имя доли (UNIQUE(envelope_id, name) для того +// и стоит). Position в ключ НЕ входит, в отличие от shareKey внутри одной +// раскладки: между двумя конвертами позиции своей истории не имеют — раскладка +// пересчитывается заново, и «еда» легко переезжает с первой строки на третью. +// +// Отрицательный остаток (пробитая save-доля) не переносится: в carried_in это +// был бы долг, вычитаемый из нового прихода молча, без единой строки в ответе. +// +// PrevSuperseded=true — прошлый конверт закрывается в день своего создания +// (период нулевой длины, ADR-008 §10): он не прожил ни дня и вытесняется +// повторной раскладкой ТОГО ЖЕ прихода. Переносится только его собственный +// carried_in — деньги, пришедшие из прошлого, реально прожитого периода; +// его allocated не переносится вовсе, потому что профинансирован тем же +// приходом, который в этот момент раскладывается заново. +// +// Без этого различения повтор «пришло 127000, разложи» наращивал перенос +// 0 → 26681 → 53362 → 65335 THB (simpleAI-faeq.10, баг 1): каждая раскладка +// уносила в carried_in накопления предыдущей, то есть тот же самый приход. +// +// Известное ограничение: второй НАСТОЯЩИЙ приход в тот же день неотличим от +// повтора раскладки — у конверта нет признака «этот приход уже был учтён». +// Выбор сделан в пользу невозможности напечатать деньги: недосчитаться +// переноса оператор увидит и поправит словами, а лишние деньги в конвертах +// он не увидит никогда. +func CarryOver(in CarryInput) []budget.EnvelopeShare { + if len(in.PrevShares) == 0 { + return ApplyCarry(in.NextShares, nil) + } + if in.PrevSuperseded { + return ApplyCarry(in.NextShares, ownCarriedIn(in.PrevShares)) + } + + prevRemaining := computeShareRemaining(in.PrevShares, in.PrevSpent, in.Rates) + carried := make([]CarriedAmount, 0, len(prevRemaining)) + for pi, rem := range prevRemaining { + if rem.Kind != budget.ShareKindSave || rem.Remaining <= 0 { + continue + } + prev := in.PrevShares[pi] + carried = append(carried, CarriedAmount{ + Name: prev.Name, + Amount: rem.Remaining, + Source: prev.Source, + Categories: prev.Categories, + }) + } + return ApplyCarry(in.NextShares, carried) +} + +// ownCarriedIn — то, что вытесненный конверт лишь ПРОНЁС через себя: carried_in +// его накопительных долей. Факт за нулевой период не считается (его нет), а +// allocated не переносится — он из текущего прихода. +func ownCarriedIn(prev []budget.EnvelopeShare) []CarriedAmount { + out := make([]CarriedAmount, 0, len(prev)) + for _, sh := range prev { + if sh.Kind != budget.ShareKindSave || sh.CarriedIn <= 0 { + continue + } + out = append(out, CarriedAmount{ + Name: sh.Name, + Amount: sh.CarriedIn, + Source: sh.Source, + Categories: sh.Categories, + }) + } + return out +} + +// CarriedAmount — накопленное, которое обязано пережить перезапись раскладки: +// сумма плюс всё, чем доля-носитель воссоздаётся, если свежей раскладке она +// больше не нужна. +type CarriedAmount struct { + Name string // отображаемое имя доли; ключ — его нормализованная форма + Amount float64 + Source string + Categories []budget.EnvelopeShareCategory +} + +// ApplyCarry кладёт накопленное в новую раскладку — единственный путь, которым +// carried_in попадает в доли: и при заведении нового конверта (CarryOver), и при +// пересчёте под правку лимита. Отдельной ветки «перенести carried_in по именам» +// быть не должно: она теряет доли-носители, которых в свежей раскладке нет, — +// PlanEnvelope лишних save-долей не выдаёт никогда. +// +// Правила (ADR-008 §9): +// - carried_in новых долей сначала обнуляется ЯВНО: доля, пришедшая откуда-то +// ещё с ненулевым carried_in, молча удвоила бы деньги; +// - имя нашлось — сумма кладётся в найденную долю; +// - имени нет — заводится save-доля с allocated=0 и carried_in=сумма, иначе +// накопленное пропало бы вместе с долей. +// +// Ключ — нормализованное имя доли (UNIQUE(envelope_id, name) для того и стоит). +// Position в ключ НЕ входит, в отличие от shareKey внутри одной раскладки: +// раскладка пересчитывается заново, и «еда» легко переезжает со строки на строку. +func ApplyCarry(nextShares []budget.EnvelopeShare, carried []CarriedAmount) []budget.EnvelopeShare { + next := make([]budget.EnvelopeShare, len(nextShares)) + copy(next, nextShares) + for i := range next { + next[i].CarriedIn = 0 + } + if len(carried) == 0 { + return next + } + + // Индекс новых долей по нормализованному имени. Дубли имён внутри одной + // раскладки невозможны (UNIQUE), первый выигрывает. + idx := make(map[string]int, len(next)) + for i := range next { + key := normalizeShareName(next[i].Name) + if _, exists := idx[key]; !exists { + idx[key] = i + } + } + + maxPos := 0 + for _, sh := range next { + if sh.Position > maxPos { + maxPos = sh.Position + } + } + + for _, c := range carried { + if c.Amount <= 0 { + continue + } + key := normalizeShareName(c.Name) + if i, ok := idx[key]; ok { + next[i].CarriedIn = c.Amount + continue + } + // Доли с таким именем в новой раскладке нет — заводим её пустой, но с + // накопленным. Категории копируются со старой доли, чтобы траты по ним + // продолжали матчиться туда же. + maxPos++ + next = append(next, budget.EnvelopeShare{ + Name: c.Name, + Kind: budget.ShareKindSave, + Allocated: 0, + CarriedIn: c.Amount, + Source: c.Source, + Position: maxPos, + Categories: c.Categories, + }) + idx[key] = len(next) - 1 + } + return next +} + +// TotalCarriedIn — сколько всего перенесено с прошлого конверта. Отдельная +// функция, а не суммирование в форматтере: строка «перенесено с прошлого раза» +// обязана показывать то же число, которое легло в carried_in долей. +func TotalCarriedIn(shares []budget.EnvelopeShare) float64 { + var total float64 + for _, sh := range shares { + total += sh.CarriedIn + } + return total +} diff --git a/internal/skills/safetospend/carry_test.go b/internal/skills/safetospend/carry_test.go new file mode 100644 index 0000000..d47216a --- /dev/null +++ b/internal/skills/safetospend/carry_test.go @@ -0,0 +1,140 @@ +package safetospend + +import ( + "testing" + + "simpleAI/internal/budget" +) + +// prevShares — прошлая раскладка: расходная «Еда» и накопительная «Накопления» +// с уже перенесённой ранее суммой (carried_in участвует в остатке наравне с +// allocated, ADR-008 §8). +func prevShares() []budget.EnvelopeShare { + return []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 20000, Position: 0, + Categories: []budget.EnvelopeShareCategory{{CategoryName: "еда"}}}, + {Name: "Накопления", Kind: budget.ShareKindSave, Allocated: 10000, CarriedIn: 1500, Position: 1}, + } +} + +func nextShares() []budget.EnvelopeShare { + return []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 18000, Position: 0, + Categories: []budget.EnvelopeShareCategory{{CategoryName: "еда"}}}, + {Name: "Накопления", Kind: budget.ShareKindSave, Allocated: 9000, Position: 1}, + } +} + +func shareByName(t *testing.T, shares []budget.EnvelopeShare, name string) budget.EnvelopeShare { + t.Helper() + for _, sh := range shares { + if sh.Name == name { + return sh + } + } + t.Fatalf("доля %q не найдена в %+v", name, shares) + return budget.EnvelopeShare{} +} + +// Накопительная доля переносит остаток (allocated + carried_in − факт), а +// расходная обнуляется: её остаток сгорает (ADR-008 §9). +func TestCarryOver_SaveCarriesSpendBurns(t *testing.T) { + // Факт: 5000 по «еде». Остаток «Еда» = 15000 (сгорает), + // «Накопления» = 10000 + 1500 − 0 = 11500 (переносится). + got := CarryOver(CarryInput{ + PrevShares: prevShares(), + PrevSpent: []budget.CategorySpentRow{{CategoryName: "еда", Currency: "THB", Amount: 5000}}, + Rates: testRates, + NextShares: nextShares(), + }) + + if food := shareByName(t, got, "Еда"); food.CarriedIn != 0 { + t.Errorf("расходная доля унесла остаток: carried_in = %.2f, ожидалось 0", food.CarriedIn) + } + save := shareByName(t, got, "Накопления") + if !eq(save.CarriedIn, 11500) { + t.Errorf("перенос накоплений = %.2f, ожидалось 11500 (10000 allocated + 1500 прошлый перенос)", save.CarriedIn) + } + if !eq(save.Allocated, 9000) { + t.Errorf("перенос затёр allocated новой раскладки: %.2f, ожидалось 9000", save.Allocated) + } + if !eq(TotalCarriedIn(got), 11500) { + t.Errorf("итого перенесено = %.2f, ожидалось 11500", TotalCarriedIn(got)) + } +} + +// Save-доля, исчезнувшая из новой раскладки, создаётся принудительно: +// allocated=0, carried_in=остаток. Иначе накопленное пропало бы вместе с долей. +func TestCarryOver_VanishedSaveShareRecreated(t *testing.T) { + next := []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 18000, Position: 0}, + } + got := CarryOver(CarryInput{ + PrevShares: prevShares(), + Rates: testRates, + NextShares: next, + }) + + save := shareByName(t, got, "Накопления") + if save.Kind != budget.ShareKindSave { + t.Errorf("восстановленная доля имеет kind %q, ожидалось %q", save.Kind, budget.ShareKindSave) + } + if save.Allocated != 0 { + t.Errorf("восстановленной доле назначен лимит %.2f, ожидалось 0", save.Allocated) + } + if !eq(save.CarriedIn, 11500) { + t.Errorf("накопления потерялись: carried_in = %.2f, ожидалось 11500", save.CarriedIn) + } + if got[len(got)-1].Position == next[0].Position { + t.Errorf("восстановленная доля встала на занятую позицию %d", got[len(got)-1].Position) + } +} + +// Перенос ищет одноимённую долю по нормализованному имени: раскладка +// пересчитывается заново, регистр и позиция доли между конвертами не совпадают. +func TestCarryOver_MatchesByNormalizedName(t *testing.T) { + next := []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 18000, Position: 0}, + {Name: " накопления ", Kind: budget.ShareKindSave, Allocated: 9000, Position: 1}, + } + got := CarryOver(CarryInput{PrevShares: prevShares(), Rates: testRates, NextShares: next}) + + if len(got) != 2 { + t.Fatalf("создана лишняя доля вместо переноса в одноимённую: %+v", got) + } + if !eq(got[1].CarriedIn, 11500) { + t.Errorf("перенос по имени не сработал: carried_in = %.2f, ожидалось 11500", got[1].CarriedIn) + } +} + +// Пробитая накопительная доля не переносит долг: отрицательный carried_in молча +// съел бы часть нового прихода. +func TestCarryOver_NegativeRemainingNotCarried(t *testing.T) { + prev := []budget.EnvelopeShare{ + {Name: "Накопления", Kind: budget.ShareKindSave, Allocated: 1000, Position: 0, + Categories: []budget.EnvelopeShareCategory{{CategoryName: "еда"}}}, + } + got := CarryOver(CarryInput{ + PrevShares: prev, + PrevSpent: []budget.CategorySpentRow{{CategoryName: "еда", Currency: "THB", Amount: 4000}}, + Rates: testRates, + NextShares: nextShares(), + }) + if c := TotalCarriedIn(got); c != 0 { + t.Errorf("перенесён долг: итого carried_in = %.2f, ожидалось 0", c) + } +} + +// Первый приход: переносить нечего, carried_in обнуляется явно — доля с +// ненулевым carried_in из чужого источника удвоила бы деньги. +func TestCarryOver_NoPreviousEnvelopeZeroes(t *testing.T) { + next := nextShares() + next[1].CarriedIn = 777 + got := CarryOver(CarryInput{Rates: testRates, NextShares: next}) + if c := TotalCarriedIn(got); c != 0 { + t.Errorf("без прошлого конверта перенесено %.2f, ожидалось 0", c) + } + if next[1].CarriedIn != 777 { + t.Errorf("CarryOver испортил входной срез вызывающего") + } +} diff --git a/internal/skills/safetospend/compute.go b/internal/skills/safetospend/compute.go index c28d4e6..ed9d136 100644 --- a/internal/skills/safetospend/compute.go +++ b/internal/skills/safetospend/compute.go @@ -5,6 +5,7 @@ package safetospend import ( + "fmt" "math" "sort" @@ -129,3 +130,93 @@ func consumptionSpentTHB(spentByCategory map[string]float64) float64 { } return total } + +// ShareRemaining — производный остаток ОДНОЙ доли конверта (ADR-008 §8): +// +// Remaining = Allocated + CarriedIn − факт по категориям доли за период +// +// Остаток не хранится нигде: у каждого слагаемого ровно один источник. +type ShareRemaining struct { + Name string + Kind string // budget.ShareKindSpend | budget.ShareKindSave + Source string // budget.ShareSourceAuto | budget.ShareSourceOverride + Allocated float64 // THB + CarriedIn float64 // THB, перенос с прошлого конверта + LimitTHB float64 // Allocated + CarriedIn — то, что показываем как «лимит» + SpentTHB float64 // факт по категориям доли, БЕЗ recurring + Remaining float64 // LimitTHB − SpentTHB, может быть отрицательным (доля пробита) +} + +// Overspent — доля пробита: потрачено больше лимита. +func (s ShareRemaining) Overspent() bool { return s.Remaining < 0 } + +// computeShareRemaining — чистая функция остатка по каждой доле (ADR-008 §8, +// §11: числа не проходят через LLM). +// +// spentByCategory — сырой факт расходов за период конверта, уже БЕЗ транзакций +// с recurring_id (их отсекает SpentByCategoryExcludingRecurring; учитывать их +// здесь значило бы посчитать обязательства дважды, ADR-008 §5). +// +// В факт доли попадают только ПЕРЕМЕННЫЕ ежедневные траты +// (budget.IsVariableDailyExpense) — по ним и строится раскладка. Фиксированные +// категории и движение денег («Переводы», «Кредит») не трогают ни одну долю: +// они уже учтены в обязательствах и показываются строкой «вне конвертов» +// (ADR-008 §4). Классификатор тот же, что у прогноза и раскладки, — один +// доменный источник. +// +// Матчинг траты к доле — budget.ResolveShare (id, затем имя, затем fallback +// «прочее»), чтобы факт не терялся молча (ADR-008 §6). Категория без своей доли +// уменьшает долю-приёмник «прочее». +// +// Порядок долей на выходе повторяет порядок входа: он задан Position раскладки. +func computeShareRemaining(shares []budget.EnvelopeShare, spentByCategory []budget.CategorySpentRow, rates map[string]float64) []ShareRemaining { + if len(shares) == 0 { + return nil + } + spent := make([]float64, len(shares)) + idx := map[string]int{} // ключ доли (position+имя) → индекс в shares + for i := range shares { + idx[shareKey(shares[i])] = i + } + + for _, row := range spentByCategory { + if !budget.IsVariableDailyExpense(row.CategoryName) { + continue // фикс и движение денег — «вне конвертов», не факт доли + } + thb, ok := budget.ToTHB(row.Amount, row.Currency, rates) + if !ok { + continue // курса нет — раздувать факт выдуманным числом нельзя + } + sh := budget.ResolveShare(shares, row.CategoryID, row.CategoryName) + if sh == nil { + continue // нет даже fallback-доли — падать некуда (ADR-008 §6) + } + if i, ok := idx[shareKey(*sh)]; ok { + spent[i] += thb + } + } + + out := make([]ShareRemaining, 0, len(shares)) + for i, sh := range shares { + limit := sh.Allocated + sh.CarriedIn + out = append(out, ShareRemaining{ + Name: sh.Name, + Kind: sh.Kind, + Source: sh.Source, + Allocated: sh.Allocated, + CarriedIn: sh.CarriedIn, + LimitTHB: limit, + SpentTHB: spent[i], + Remaining: limit - spent[i], + }) + } + return out +} + +// shareKey — идентификатор доли внутри одной раскладки. ID использовать нельзя: +// у долей, посчитанных PlanEnvelope и ещё не сохранённых, он нулевой, и все +// доли слиплись бы в одну. Имя уникально в пределах конверта (UNIQUE(envelope_id, +// name), ADR-008 §9), position добавлен как страховка от рассинхронизации. +func shareKey(sh budget.EnvelopeShare) string { + return fmt.Sprintf("%d|%s", sh.Position, normalizeShareName(sh.Name)) +} diff --git a/internal/skills/safetospend/config.go b/internal/skills/safetospend/config.go index cfa3b4d..82d28a0 100644 --- a/internal/skills/safetospend/config.go +++ b/internal/skills/safetospend/config.go @@ -19,4 +19,46 @@ const ( categoriesTopN = 5 // defaultLLMTimeout — таймаут LLM-нарратива, независимый от Telegram-хендлера. defaultLLMTimeout = 45 * time.Second + // minShareMonthlyTHB — минимальный МЕСЯЧНЫЙ лимит категории, ниже которого + // отдельный конверт не заводится: копеечные лимиты человек не отслеживает, + // они только шумят в раскладке. Такие категории сливаются в «прочее». + // + // Планка абсолютная и от прихода НЕ зависит — это регрессия simpleAI-faeq.10. + // Порог «доля свободных денег» двигался вместе с обязательствами (сдвинулась + // next_date платежа — сдвинулся порог), и категория с постоянным прогнозом + // перепрыгивала его туда-обратно: «Транспорт 985» и «Здоровье 798» то были + // конвертами, то молча уезжали в «прочее» и накопления. Набор конвертов — + // свойство трат человека, а не остатка после обязательств. + // + // Пересчитывается на длину периода теми же days/prorationBaseDays, что и сам + // прогноз: сравнивать месячную планку с недельным лимитом нельзя. + minShareMonthlyTHB = 500.0 + // minHistoryMonths — минимум ПОЛНЫХ месяцев истории по категории, чтобы + // назначать ей собственный лимит. Меньше — среднее по одному месяцу является + // не статистикой, а случайным числом; лимит не выдумываем (ADR-008). + minHistoryMonths = 2 + // savingsShareName — имя доли-накопления: в неё уходит всё, что не покрыто + // лимитами трат. Kind = budget.ShareKindSave, остаток переносится дальше. + savingsShareName = "накопления" + // adviceLineRunes — предел длины одной строки совета от LLM, В СИМВОЛАХ. + // Байтовый предел на кириллице резал бы символ пополам. + adviceLineRunes = 200 + // lowDataNamesShown — сколько категорий без истории называть поимённо в + // предупреждении. Остальные сворачиваются в «и ещё N»: живой прогон дал + // одиннадцать таких категорий, и одиннадцать строк шума прятали сам ответ. + lowDataNamesShown = 3 + // fixedFundingDays — окно финансирования регулярных платежей: платёж + // финансируется этим приходом, если его дата попадает в месяц вперёд от + // начала периода. Не период конверта: аренда 10.09 при периоде до 06.09 всё + // равно должна быть отложена сейчас, иначе платёж встретит пустой карман + // (sinking fund). Месяц — потому что все регулярные платежи месячные, и + // более широкое окно заложило бы один платёж дважды. + fixedFundingDays = 31 + // kopecksInUnit — округление сумм долей до копейки: раскладка показывается + // человеку, а не хранит доли рубля. + kopecksInUnit = 100.0 ) + +// lowShareFraction — доля лимита, ниже которой конверт помечается жёлтым +// («скоро кончится»). Порог отображения, на счёт не влияет. +const lowShareFraction = 0.2 diff --git a/internal/skills/safetospend/display.go b/internal/skills/safetospend/display.go new file mode 100644 index 0000000..e923e6d --- /dev/null +++ b/internal/skills/safetospend/display.go @@ -0,0 +1,94 @@ +package safetospend + +import ( + "fmt" + "strings" +) + +// Валюта ОТОБРАЖЕНИЯ конвертов. +// +// Хранение долей не трогается: allocated / carried_in остаются в THB — единой +// расчётной валюте проекта (ADR-008 §7). Меняется только представление, то есть +// последний шаг перед печатью. Это принципиально: если бы валюту отображения +// пустили внутрь расчёта, остаток доли начал бы дрейфовать от движения курса +// без единой траты — ровно то, что §7 запрещает. +// +// Дефолт — THB: оператор живёт и тратит в батах, а рубли ему нужны как перевод +// («сколько это дома»), а не как основная шкала. + +// DefaultDisplayCurrency — валюта конвертов, когда её не назвали. +const DefaultDisplayCurrency = "THB" + +// Display — как печатать сумму, хранящуюся в THB. +// +// Курс лежит внутри и рядом с кодом валюты не случайно: печать обязана +// показывать те же деньги, по которым посчитаны доли. Разъехавшиеся «код +// валюты отдельно, курс отдельно» уже дали бы возможность напечатать баты по +// рублёвому числу. +type Display struct { + Code string // "THB" | "RUB" (прочие коды — как есть) + RubPerTHB float64 // ₽ за 1 ฿ +} + +// NewDisplay собирает валюту отображения. Пустой / неизвестный код — дефолт THB. +// +// Неизвестная валюта тоже уводится в дефолт, а не печатается своим кодом: у нас +// нет курса ни к чему, кроме рубля (rates["THB"] = ₽ за ฿), и напечатать сумму +// «в долларах» мы бы всё равно не смогли — получились бы баты с чужим знаком. +func NewDisplay(code string, rubPerTHB float64) Display { + switch strings.ToUpper(strings.TrimSpace(code)) { + case "RUB", "RUR", "₽": + return Display{Code: "RUB", RubPerTHB: rubPerTHB} + default: + return Display{Code: DefaultDisplayCurrency, RubPerTHB: rubPerTHB} + } +} + +// Amount переводит хранимую сумму THB в валюту отображения. +func (d Display) Amount(thb float64) float64 { + if d.Code == "RUB" { + return thb * d.RubPerTHB + } + return thb +} + +// Sign — знак валюты для печати. +func (d Display) Sign() string { + if d.Code == "RUB" { + return "₽" + } + return "฿" +} + +// Fmt — готовая строка «сумма знак». Единственная точка, где число встречается +// со знаком валюты: пока формат собирался руками в каждом Fprintf, ничто не +// мешало напечатать рублёвое число с батовым знаком. +// Разряды группируются пробелом, копеек нет: негруппированные цифры человек +// считает справа налево, а десятые в сводке — шум (ресёрч вёрстки, п. П2.5). +func (d Display) Fmt(thb float64) string { + return fmt.Sprintf("%s %s", groupDigits(roundInt(d.Amount(thb))), d.Sign()) +} + +// ParseDisplayCurrency вытаскивает валюту конвертов из фразы оператора: +// «покажи конверты в рублях» → RUB, «в батах» → THB. Не нашли — "" (решает +// вызывающий, у него есть свой дефолт). +// +// Разбор по тексту нужен как страховка рядом с полем в input скилла: поле +// заполняет LLM, и её молчание не должно превращаться в «оператор не просил». +// Ищем корень С ПРЕДЛОГОМ («в рублях»), а не голый корень: тот же разбор служит +// страховкой поверх описания траты в budget-скилле, и «купил рубленый стейк» +// молча переключал бы весь ответ на рубли. +func ParseDisplayCurrency(text string) string { + t := strings.ToLower(text) + for _, w := range []string{"в рубл", "в ₽", "в rub"} { + if strings.Contains(t, w) { + return "RUB" + } + } + for _, w := range []string{"в бат", "в ฿", "в thb"} { + if strings.Contains(t, w) { + return "THB" + } + } + return "" +} diff --git a/internal/skills/safetospend/display_test.go b/internal/skills/safetospend/display_test.go new file mode 100644 index 0000000..ffa454e --- /dev/null +++ b/internal/skills/safetospend/display_test.go @@ -0,0 +1,138 @@ +package safetospend + +import ( + "strings" + "testing" + "time" + + "simpleAI/internal/budget" +) + +// planForDisplay — раскладка со всеми тремя видами долей: запертый регулярный +// платёж, гибкий конверт и накопления с переносом. В печати участвуют все три, +// и валюту каждой проверяет тест. +func planForDisplay() EnvelopePlan { + due := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC) + return EnvelopePlan{ + Result: Result{ + IncomeTHB: 10000, + RecurringTHB: 1000, + DebtTHB: 0, + FreeAfterObligations: 9000, + }, + Shares: []budget.EnvelopeShare{ + {Name: "аренда", Kind: budget.ShareKindFixed, Allocated: 1000, DueDate: &due, Position: 0}, + {Name: "еда", Kind: budget.ShareKindSpend, Allocated: 5000, Position: 1}, + {Name: "накопления", Kind: budget.ShareKindSave, Allocated: 4000, CarriedIn: 2000, Position: 2}, + }, + } +} + +func replyForDisplay(m Display) EnvelopeReply { + from := time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC) + return EnvelopeReply{ + Plan: planForDisplay(), + RubPerTHB: 2, + Display: m, + Period: "ближайшие 2 недели", + From: from, + To: from.AddDate(0, 0, 13), + IncomeAmount: 20000, + IncomeCurrency: "RUB", + } +} + +// Дефолт раскладки — баты: суммы конвертов печатаются как есть (хранение уже в +// THB), рублёвого знака в них нет. Курс при этом показывается всегда — он +// подпись к заголовку, а не валюта сумм. +func TestFormatEnvelopePlan_DefaultTHB(t *testing.T) { + out := FormatEnvelopePlan(replyForDisplay(NewDisplay("", 2))) + for _, want := range []string{ + "Пришло 20 000 ₽ · 10 000 ฿", + "Перенос с прошлого раза 2 000 ฿", + "Аренда 10.09 1 000", + "Еда 5 000", + "Накопления 6 000", + " 12 000", + "**На день: 357 ฿**", + } { + if !strings.Contains(out, want) { + t.Errorf("нет строки %q в батовой раскладке:\n%s", want, out) + } + } +} + +// Просьба показать в рублях переводит ВСЕ суммы конвертов по курсу 2 ₽/฿. +// Мутация «форматтер всегда печатает одну валюту» роняет либо этот тест, либо +// предыдущий — оба одновременно пройти не могут. +func TestFormatEnvelopePlan_DisplayRUB(t *testing.T) { + out := FormatEnvelopePlan(replyForDisplay(NewDisplay("RUB", 2))) + for _, want := range []string{ + "Пришло 20 000 ₽", + "Перенос с прошлого раза 4 000 ₽", + "Аренда 10.09 2 000", + "Еда 10 000", + "Накопления 12 000", + " 24 000", + "**На день: 714 ₽**", + } { + if !strings.Contains(out, want) { + t.Errorf("нет строки %q в рублёвой раскладке:\n%s", want, out) + } + } + // Знак ฿ остаётся только в подписи курса «₽/฿» — сумм в батах быть не должно. + if strings.Contains(out, " ฿") { + t.Errorf("просили рубли, а суммы напечатаны батами:\n%s", out) + } +} + +// Хранение не зависит от валюты показа: одна и та же раскладка (те же THB в +// Shares) печатается двумя валютами, и рублёвая ровно вдвое больше батовой при +// курсе 2 ₽/฿ — то есть перевод один, а не двойной. +func TestDisplay_AmountConvertsOnce(t *testing.T) { + thb := NewDisplay("THB", 2) + rub := NewDisplay("RUB", 2) + if got := thb.Amount(5000); got != 5000 { + t.Errorf("баты обязаны печататься как хранятся: %v", got) + } + if got := rub.Amount(5000); got != 10000 { + t.Errorf("ожидали 10000 ₽ из 5000 ฿ при курсе 2, получили %v", got) + } +} + +// Неназванная и неизвестная валюта уходят в дефолт (баты), а не печатаются +// чужим знаком: курса ни к чему, кроме рубля, у нас нет. +func TestNewDisplay_Default(t *testing.T) { + for _, code := range []string{"", " ", "USD", "кому-то"} { + if got := NewDisplay(code, 2); got.Code != DefaultDisplayCurrency { + t.Errorf("NewDisplay(%q) = %q, ожидали %q", code, got.Code, DefaultDisplayCurrency) + } + } + if got := NewDisplay("rub", 2); got.Code != "RUB" { + t.Errorf("NewDisplay(\"rub\") = %q", got.Code) + } +} + +// Разбор валюты из фразы оператора — страховка на случай, когда LLM не +// заполнила поле. Ловится корень С ПРЕДЛОГОМ («в рублях»): голый корень дал бы +// ложное срабатывание на «рубленом стейке» в описании траты. +func TestParseDisplayCurrency(t *testing.T) { + cases := map[string]string{ + "покажи конверты в рублях": "RUB", + "сколько это в рублях": "RUB", + "покажи конверты в батах": "THB", + "на еду хватит 5000 бат": "", + "сколько осталось на еду": "", + "разложи приход по конверт": "", + // Корень «рубл» без предлога — не просьба о валюте: этот же разбор + // служит страховкой поверх описания траты, и «рубленый стейк» не + // должен переключать весь ответ на рубли. + "купил рубленый стейк": "", + "батарейки 300": "", + } + for q, want := range cases { + if got := ParseDisplayCurrency(q); got != want { + t.Errorf("ParseDisplayCurrency(%q) = %q, ожидали %q", q, got, want) + } + } +} diff --git a/internal/skills/safetospend/envelope_invariant_test.go b/internal/skills/safetospend/envelope_invariant_test.go new file mode 100644 index 0000000..03d2b60 --- /dev/null +++ b/internal/skills/safetospend/envelope_invariant_test.go @@ -0,0 +1,258 @@ +package safetospend + +import ( + "math" + "testing" + "time" + + "simpleAI/internal/budget" +) + +// Регрессии, вскрытые прогоном на реплике 2026-08-24 (simpleAI-faeq.10). +// +// Общий инвариант, которого не хватало и который эти тесты держат: +// +// Σ allocated + Σ carried_in ≤ приход + реальный остаток прошлого периода +// +// «Реальный остаток» — остаток конверта, который ПРОЖИЛ хотя бы день. Конверт, +// закрытый в день своего создания, не прожил ничего: его allocated профинансирован +// тем же приходом, который сейчас раскладывается заново, и перенос этого allocated +// печатает деньги из воздуха. + +// checkEnvelopeInvariant — сумма всех конвертов не превышает приход плюс реальный +// остаток прошлого периода. Отдельно от checkConvergence: тот проверяет равенство +// на Allocated внутри одной раскладки, этот — верхнюю границу на Allocated+CarriedIn +// между раскладками. +func checkEnvelopeInvariant(t *testing.T, shares []budget.EnvelopeShare, incomeTHB, prevRealRemainder float64) { + t.Helper() + var total float64 + for _, sh := range shares { + total += sh.Allocated + sh.CarriedIn + } + limit := incomeTHB + prevRealRemainder + if total > limit+0.005 { + t.Errorf("Σ (allocated + carried_in) = %.2f > приход %.2f + реальный остаток %.2f = %.2f — деньги взялись из ниоткуда", + total, incomeTHB, prevRealRemainder, limit) + } +} + +// Баг 1 (реплика 2026-08-24): повторная раскладка ТОГО ЖЕ прихода в тот же день +// наращивала carried_in 0 → 26681 → 53362 → 65335 THB. +// +// Механика: конверт, заведённый и закрытый в один день, имеет период нулевой +// длины (ADR-008 §10), факта по нему нет, и CarryOver переносил его остаток +// целиком — то есть allocated «накоплений», профинансированный тем же приходом, +// который в этот момент раскладывается заново. +// +// Правильное поведение: у вытесненного конверта переносится только ЕГО +// собственный carried_in (он пришёл из прошлого, реально прожитого периода), +// а его allocated не переносится вовсе. +func TestCarryOver_SupersededEnvelopeDoesNotMultiplyCarriedIn(t *testing.T) { + prev := []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 5384, Position: 0}, + {Name: "Накопления", Kind: budget.ShareKindSave, Allocated: 26681, CarriedIn: 1500, Position: 1}, + } + got := CarryOver(CarryInput{ + PrevShares: prev, + Rates: testRates, + NextShares: nextShares(), + PrevSuperseded: true, + }) + + save := shareByName(t, got, "Накопления") + if !eq(save.CarriedIn, 1500) { + t.Errorf("перенос с вытесненного конверта = %.2f, ожидалось 1500 (только его собственный carried_in; allocated 26681 профинансирован тем же приходом)", save.CarriedIn) + } +} + +// Повтор раскладки не должен ничего накапливать: три подряд «пришло 127000, +// разложи» в один день обязаны дать одинаковый результат. +func TestCarryOver_RepeatedSameDayPlanIsIdempotent(t *testing.T) { + const incomeTHB = 40968 + + plan := func() []budget.EnvelopeShare { + return []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 5384, Position: 0}, + {Name: "Накопления", Kind: budget.ShareKindSave, Allocated: incomeTHB - 5384, Position: 1}, + } + } + + var prev []budget.EnvelopeShare + for i := 1; i <= 3; i++ { + cur := CarryOver(CarryInput{ + PrevShares: prev, + Rates: testRates, + NextShares: plan(), + PrevSuperseded: prev != nil, // прошлый конверт закрыт в день создания + }) + // Прошлый период не прожил ни дня → реального остатка нет. + checkEnvelopeInvariant(t, cur, incomeTHB, 0) + if c := TotalCarriedIn(cur); !eq(c, 0) { + t.Fatalf("раскладка #%d: перенесено %.2f, ожидалось 0 — повтор той же раскладки печатает деньги", i, c) + } + prev = cur + } +} + +// Прожитый период переносится как раньше: PrevSuperseded не должен превратиться +// в «перенос выключен всегда» — это стёрло бы реальные накопления. +func TestCarryOver_LivedEnvelopeStillCarriesRemaining(t *testing.T) { + got := CarryOver(CarryInput{ + PrevShares: prevShares(), + PrevSpent: []budget.CategorySpentRow{{CategoryName: "еда", Currency: "THB", Amount: 5000}}, + Rates: testRates, + NextShares: nextShares(), + }) + if save := shareByName(t, got, "Накопления"); !eq(save.CarriedIn, 11500) { + t.Errorf("прожитый конверт перенёс %.2f, ожидалось 11500", save.CarriedIn) + } +} + +// Баг 3 (реплика 2026-08-24): «Транспорт 985» и «Здоровье 798» то появлялись +// отдельными конвертами, то молча уезжали в «прочее» и накопления. +// +// Механика: порог схлопывания мелочи считался как доля СВОБОДНЫХ денег +// (free * minShareFraction). free двигается от прихода к приходу (обязательства +// со сдвигом next_date), порог двигается вместе с ним, и категория с фиксированным +// прогнозом перепрыгивает планку туда-обратно. +// +// Правильное поведение: планка не зависит от прихода — она про то, «отслеживает ли +// человек такой конверт», а это свойство суммы, а не остатка после обязательств. +func TestAllocateShares_CategorySetDoesNotDependOnFree(t *testing.T) { + fc := []budget.CategoryForecast{ + fcRUB("Еда", 11850), + fcRUB("Транспорт", 2110), + fcRUB("Здоровье", 1710), + } + history := map[string]int{"еда": 3, "транспорт": 3, "здоровье": 3} + + names := func(free float64) []string { + shares, _ := allocateShares(free, fc, oneToOne, 14, nil, history) + var out []string + for _, sh := range shares { + if sh.Allocated > 0 && sh.Kind == budget.ShareKindSpend { + out = append(out, sh.Name) + } + } + return out + } + + // Тот же прогноз, разный остаток после обязательств. + rich := names(41000) + poor := names(23000) + if len(rich) != len(poor) { + t.Fatalf("набор конвертов зависит от прихода: при free=41000 %v, при free=23000 %v", rich, poor) + } + for i := range rich { + if rich[i] != poor[i] { + t.Fatalf("набор конвертов зависит от прихода: при free=41000 %v, при free=23000 %v", rich, poor) + } + } + for _, want := range []string{"Транспорт", "Здоровье"} { + found := false + for _, n := range rich { + if n == want { + found = true + } + } + if !found { + t.Errorf("категория %q с трёхмесячной историей не получила своего конверта: %v", want, rich) + } + } +} + +// Инвариант на всей раскладке: конверты не обещают больше, чем есть. +func TestPlanEnvelope_InvariantHolds(t *testing.T) { + in := EnvelopePlanInput{ + IncomeTHB: 40968, + Snapshot: &budget.AdvisorSnapshot{}, + Forecast: []budget.CategoryForecast{fcRUB("Еда", 11850), fcRUB("Транспорт", 2110)}, + Rates: oneToOne, + Days: 14, + History: map[string]int{"еда": 3, "транспорт": 3}, + } + plan := PlanEnvelope(in) + checkEnvelopeInvariant(t, plan.Shares, in.IncomeTHB, 0) + + var total float64 + for _, sh := range plan.Shares { + total += sh.Allocated + } + if math.Abs(total-in.IncomeTHB) > 0.005 { + t.Errorf("Σ allocated = %.2f, приход = %.2f — итог обязан сходиться с приходом до бата", total, in.IncomeTHB) + } +} + +// Регулярные платежи становятся видимыми конвертами, а не скрытым вычетом +// (simpleAI-faeq.11 §1–§4): каждый — своей строкой с датой, включая платёж ЗА +// границей периода, а итог сходится с приходом до бата. +func TestPlanEnvelope_RecurringBecomeVisibleShares(t *testing.T) { + from := time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC) + rec := []budget.RecurringPayment{ + {Name: "аренда", Type: "expense", Amount: 18000, Currency: "THB", Enabled: true, + NextDate: time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)}, // ЗА границей периода + {Name: "Кредит потребительский Сбербанк", Type: "expense", Amount: 28500, Currency: "RUB", Enabled: true, + NextDate: time.Date(2026, 8, 27, 0, 0, 0, 0, time.UTC)}, + {Name: "кредитная карта", Type: "expense", Amount: 12500, Currency: "RUB", Enabled: false, + NextDate: time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC)}, // выключен — не платёж + {Name: "зарплата", Type: "income", Amount: 100000, Currency: "RUB", Enabled: true, + NextDate: time.Date(2026, 9, 5, 0, 0, 0, 0, time.UTC)}, // доход — не расход + {Name: "страховка", Type: "expense", Amount: 50000, Currency: "RUB", Enabled: true, + NextDate: time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC)}, // вне окна финансирования + } + rates := map[string]float64{"RUB": 1, "THB": 3.1} + + plan := PlanEnvelope(EnvelopePlanInput{ + IncomeTHB: 40967.74, + // Сводная сумма обязательств из снимка обязана быть ПОДМЕНЕНА суммой + // пофамильных конвертов, а не сложена с ней: это одни и те же деньги. + Snapshot: &budget.AdvisorSnapshot{UpcomingRecurring: 99999}, + Forecast: []budget.CategoryForecast{{CategoryName: "Еда", Currency: "THB", ForecastAmount: 11571}}, + Rates: rates, + Days: 14, + History: map[string]int{"еда": 3}, + Recurring: rec, + From: from, + }) + + fixed := FixedShares(plan.Shares) + if len(fixed) != 2 { + t.Fatalf("видимых платежей %d, ожидалось 2 (аренда и кредит): %+v", len(fixed), fixed) + } + if fixed[0].Name != "аренда" || !eq(fixed[0].Allocated, 18000) { + t.Errorf("первым платежом ожидалась аренда 18000: %+v", fixed[0]) + } + if fixed[0].DueDate == nil || fixed[0].DueDate.Format("02.01") != "10.09" { + t.Errorf("платёж за границей периода потерял дату: %+v", fixed[0]) + } + if !eq(plan.Result.RecurringTHB, 18000+roundKopecks(28500/3.1)) { + t.Errorf("обязательства = %.2f — сводная сумма снимка не подменена суммой конвертов", plan.Result.RecurringTHB) + } + + var total float64 + for _, sh := range plan.Shares { + total += sh.Allocated + } + if math.Abs(total-40967.74) > 0.005 { + t.Errorf("Σ конвертов = %.2f, приход = 40967.74 — итог обязан сходиться до бата", total) + } + checkEnvelopeInvariant(t, plan.Shares, 40967.74, 0) +} + +// Малый приход не ломает раскладку: конверты урезаются, дневной лимит считается +// от них, а не от прихода (simpleAI-faeq.11 §5). +func TestPlanEnvelope_TinyIncomeStillPlans(t *testing.T) { + plan := PlanEnvelope(EnvelopePlanInput{ + IncomeTHB: 3.2, // «пришло 10 рублей» + Snapshot: &budget.AdvisorSnapshot{}, + Forecast: []budget.CategoryForecast{{CategoryName: "Еда", Currency: "THB", ForecastAmount: 11571}}, + Rates: map[string]float64{"RUB": 1, "THB": 3.1}, + Days: 14, + History: map[string]int{"еда": 3}, + From: time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC), + }) + checkEnvelopeInvariant(t, plan.Shares, 3.2, 0) + if DailyLimit(FlexibleTHB(plan.Shares), 14) < 0 { + t.Error("дневной лимит ушёл в минус") + } +} diff --git a/internal/skills/safetospend/format.go b/internal/skills/safetospend/format.go index cb490cb..e86ee62 100644 --- a/internal/skills/safetospend/format.go +++ b/internal/skills/safetospend/format.go @@ -2,9 +2,13 @@ package safetospend import ( "fmt" + "math" "strings" + "time" "unicode" "unicode/utf8" + + "simpleAI/internal/budget" ) // replyData — всё, что нужно для ответа. Числа детерминированы (из Result), @@ -64,6 +68,332 @@ func formatReply(d replyData) string { return b.String() } +// EnvelopeReply — данные для ответа о заведённом конверте с раскладкой +// (ADR-008). Числа приходят готовыми из PlanEnvelope — форматтер только +// печатает, не считает (числа не проходят ни через LLM, ни через вёрстку). +type EnvelopeReply struct { + Plan EnvelopePlan + RubPerTHB float64 // ₽ за 1 ฿ (курс показывается всегда, любой валютой) + Display Display // валюта отображения конвертов (дефолт — THB) + Period string // человекочитаемый горизонт («ближайшие 2 недели») + From, To time.Time + IncomeAmount float64 // приход как его назвал оператор + IncomeCurrency string +} + +// Ширина колонок моноблока. Сумма — 18+6+8 = 32 знака, с запасом под порог 36 +// из ресёрча вёрстки: pre в Telegram НЕ переносит строки по словам, длинная +// строка уезжает в горизонтальный скролл на узком экране. +// +// Колонок ровно две с половиной: метка, дата платежа (у гибких долей пустая) и +// сумма по правому краю. Третья полноценная колонка (процент, остаток) на +// телефоне уже не помещается. +const ( + labelWidth = 18 + dueWidth = 6 + amountWidth = 8 +) + +// FormatEnvelopePlan печатает раскладку прихода по конвертам в формате, +// утверждённом оператором 2026-08-24 (simpleAI-faeq.11). +// +// Что здесь принципиально, помимо вёрстки: +// +// 1. Траты НЕ делятся на «обязательные» и «на жизнь» — один список. Прямая +// цитата оператора: «есть мне тоже надо, или ты считаешь что еда +// необязательна?». Разница между строками только в том, что у части +// известны сумма и дата, и это видно самими колонками, а не заголовком. +// 2. Каждый регулярный платёж — своей строкой с датой и настоящим именем. +// Сводная строка «обязательства 12332» прятала и сумму, и повод, из-за чего +// приход визуально не сходился. +// 3. Итог сходится с приходом до бата: колонка складывается ровно в шапку. +// Обеспечивается тем, что накопления забирают ошибку округления всех +// остальных строк — они и по расчёту непокрытый остаток (ADR-008 §6). +// 4. Эмодзи нет ни одного внутри pre: в моноширинном блоке эмодзи шириной ≈2 +// знака и рендерится по-разному на iOS/Android/Desktop — колонка едет. +// 5. Разделитель блоков — пустая строка. Линия из символов в пропорциональном +// шрифте имеет случайную длину и сама становится мусором. +func FormatEnvelopePlan(d EnvelopeReply) string { + m := d.Display + if m.RubPerTHB == 0 { + m.RubPerTHB = d.RubPerTHB + } + if m.Code == "" { + m = NewDisplay("", d.RubPerTHB) + } + days := envelopeDays(d.From, d.To) + var b strings.Builder + + // Шапка: период, приход, курс. Обычным текстом — крупно и без колонок. + fmt.Fprintf(&b, "%s — %s · %d %s\n", + d.From.Format("02.01"), d.To.Format("02.01"), days, pluralDays(days)) + fmt.Fprintf(&b, "Пришло %s\n", incomeLine(d, m)) + // Перенос — отдельная строка и НЕ складывается с приходом: там деньги + // этого прихода, здесь — прошлого. Свернув их в одну сумму, мы показали бы + // приход больше, чем он есть. Но в колонку он входит: конверты им наполнены. + if carried := TotalCarriedIn(d.Plan.Shares); carried > 0 { + fmt.Fprintf(&b, "Перенос с прошлого раза %s\n", m.Fmt(carried)) + } + fmt.Fprintf(&b, "Курс %s ₽/฿ на %s\n", decimalComma(d.RubPerTHB), d.From.Format("02.01")) + + // Один моноблок на всё сообщение: колонка чисел в Telegram держится ТОЛЬКО + // внутри pre — системный шрифт пропорциональный, и выравнивания пробелами + // вне моноблока не существует. + b.WriteString("\n**Куда уйдут**\n```\n") + rows := envelopeRows(d.Plan.Shares, m, roundInt(m.Amount(shareTotalTHB(d.Plan.Shares)))) + var lineSum int + for _, r := range rows { + lineSum += r.amount + fmt.Fprintf(&b, "%s%s%s\n", + padRight(r.label, labelWidth), padLeft(r.due, dueWidth), padLeft(groupDigits(r.amount), amountWidth)) + } + totalStr := groupDigits(lineSum) + fmt.Fprintf(&b, "%s\n", padLeft(strings.Repeat("-", utf8.RuneCountInString(totalStr)), labelWidth+dueWidth+amountWidth)) + fmt.Fprintf(&b, "%s\n```\n", padLeft(totalStr, labelWidth+dueWidth+amountWidth)) + + // Главное число — одно и внизу отдельным блоком: по нему оператор + // действует сегодня. Приход в его формуле не участвует (см. DailyLimit). + fmt.Fprintf(&b, "\n**На день: %s**\n", m.Fmt(DailyLimit(FlexibleTHB(d.Plan.Shares), days))) + b.WriteString(dailyLimitScope(d.Plan.Shares)) + + for _, w := range d.Plan.Warnings { + fmt.Fprintf(&b, "\n\n⚠️ %s", w) + } + return b.String() +} + +// envelopeRow — одна строка моноблока: метка, дата платежа (или пусто) и сумма +// уже в валюте показа и уже целая. Округление делается ОДИН раз здесь, потому +// что складываться в итог обязаны именно напечатанные числа. +type envelopeRow struct { + label string + due string + amount int +} + +// envelopeRows раскладывает доли по строкам. Порядок: сначала регулярные +// платежи (у них есть дата и они уже отложены), затем гибкие конверты, затем +// накопления — тот же порядок, в котором деньги распределяются. +// +// total — точная сумма всех долей в валюте показа, целая. Накопления получают +// РОВНО остаток до неё: округление каждой строки в отдельности даёт расхождение +// до бата на строку, и без этого шага колонка не сложилась бы в свой же итог. +// +// Опорой служит сумма ДОЛЕЙ, а не приход: форматтер не имеет права дописывать +// деньги, которых в раскладке нет. Что доли складываются ровно в приход — +// инвариант PlanEnvelope (ADR-008 §4), и проверяется он там, а не здесь. +func envelopeRows(shares []budget.EnvelopeShare, m Display, total int) []envelopeRow { + rows := make([]envelopeRow, 0, len(shares)) + var savings *envelopeRow + var assigned int + + add := func(sh budget.EnvelopeShare) envelopeRow { + r := envelopeRow{ + label: shareLabel(sh.Name), + amount: roundInt(m.Amount(sh.Allocated + sh.CarriedIn)), + } + if sh.DueDate != nil { + r.due = sh.DueDate.Format("02.01") + } + return r + } + for _, group := range [][]budget.EnvelopeShare{FixedShares(shares), SpendShares(shares)} { + for _, sh := range group { + r := add(sh) + assigned += r.amount + rows = append(rows, r) + } + } + for _, sh := range SaveShares(shares) { + r := add(sh) + if savings == nil { + savings = &r + continue + } + assigned += r.amount + rows = append(rows, r) + } + if savings != nil { + // Ошибка округления всех строк оседает здесь — накопления и по расчёту + // непокрытый остаток, а не самостоятельный лимит (ADR-008 §6). + savings.amount = total - assigned + if savings.amount < 0 { + savings.amount = 0 + } + rows = append(rows, *savings) + } + return rows +} + +// shareTotalTHB — точная сумма раскладки: лимиты плюс перенесённое. +func shareTotalTHB(shares []budget.EnvelopeShare) float64 { + var total float64 + for _, sh := range shares { + total += sh.Allocated + sh.CarriedIn + } + return total +} + +// dailyLimitScope — предложение под дневным лимитом: что в него входит и что +// уже отложено. Без него число «814 ฿» читается как «всё, что у меня есть». +func dailyLimitScope(shares []budget.EnvelopeShare) string { + flexible := make([]string, 0, len(shares)) + for _, sh := range SpendShares(shares) { + flexible = append(flexible, shareLabel(sh.Name)) + } + fixed := make([]string, 0, len(shares)) + for _, sh := range FixedShares(shares) { + fixed = append(fixed, shareLabel(sh.Name)) + } + return scopeSentence(flexible, fixed) +} + +// scopeSentence собирает предложение из уже готовых имён: гибкие перечислением, +// фиксированные — «уже отложены». Общая для раскладки прихода и показа +// конвертов: два ответа об одних и тех же деньгах обязаны звучать одинаково. +func scopeSentence(flexible, fixed []string) string { + for i := range flexible { + if i > 0 { + flexible[i] = lowerFirst(flexible[i]) + } + } + var b strings.Builder + if len(flexible) > 0 { + fmt.Fprintf(&b, "%s.", strings.Join(flexible, ", ")) + } + if len(fixed) > 0 { + if b.Len() > 0 { + b.WriteString(" ") + } + fmt.Fprintf(&b, "%s — уже отложены.", strings.Join(fixed, ", ")) + } + return b.String() +} + +// incomeLine — приход в шапке. Валюту, которой его назвал оператор, показываем +// первой и всегда: «пришло 127000₽» обязано остаться 127000₽, иначе он не +// узнает свой собственный приход. Расчётная сумма идёт рядом — и только если +// это другая валюта, иначе строка дублировала бы сама себя. +func incomeLine(d EnvelopeReply, m Display) string { + own := fmt.Sprintf("%s %s", groupDigits(roundInt(d.IncomeAmount)), currencySign(d.IncomeCurrency)) + shown := m.Fmt(d.Plan.Result.IncomeTHB) + if currencySign(d.IncomeCurrency) == m.Sign() { + return shown + } + return own + " · " + shown +} + +// shareLabel ужимает имя доли под ширину колонки по границе СЛОВА: обрезанное +// посередине «Кредит потребит…» читается хуже, чем честное «Кредит». Ellipsis +// остаётся страховкой на случай, когда не помещается даже первое слово. +func shareLabel(name string) string { return shareLabelWidth(name, labelWidth) } + +// shareLabelWidth — то же ужатие под ПРОИЗВОЛЬНУЮ ширину колонки: показ +// конвертов печатает два числа вместо одного, и имени достаётся меньше места. +func shareLabelWidth(name string, labelWidth int) string { + label := normalizeLabel(name) + if utf8.RuneCountInString(label) <= labelWidth { + return label + } + var out string + for _, w := range strings.Fields(label) { + next := w + if out != "" { + next = out + " " + w + } + if utf8.RuneCountInString(next) > labelWidth { + break + } + out = next + } + if out != "" { + return out + } + return string([]rune(label)[:labelWidth-1]) + "…" +} + +// envelopeDays — длина периода в днях, обе границы включительно. +func envelopeDays(from, to time.Time) int { + d := int(to.Sub(from).Hours()/24) + 1 + if d < 1 { + d = 1 + } + return d +} + +func pluralDays(n int) string { + switch { + case n%10 == 1 && n%100 != 11: + return "день" + case n%10 >= 2 && n%10 <= 4 && (n%100 < 12 || n%100 > 14): + return "дня" + } + return "дней" +} + +// groupDigits — разряды через пробел, без копеек (ресёрч вёрстки: группировка +// читается в один проход, дробная часть в сводке — шум). +func groupDigits(n int) string { + sign := "" + if n < 0 { + sign, n = "-", -n + } + s := fmt.Sprintf("%d", n) + var parts []string + for len(s) > 3 { + parts = append([]string{s[len(s)-3:]}, parts...) + s = s[:len(s)-3] + } + return sign + strings.Join(append([]string{s}, parts...), " ") +} + +// decimalComma — курс с запятой: русский текст, «3.1» в нём читается как сбой. +func decimalComma(v float64) string { + return strings.Replace(fmt.Sprintf("%.1f", v), ".", ",", 1) +} + +func roundInt(v float64) int { return int(math.Round(v)) } + +// padRight / padLeft считают ширину В РУНАХ: %-18s в Go меряет БАЙТЫ, и на +// кириллице колонка разъезжается ровно вдвое. +func padRight(s string, width int) string { + if pad := width - utf8.RuneCountInString(s); pad > 0 { + return s + strings.Repeat(" ", pad) + } + return s +} + +func padLeft(s string, width int) string { + if pad := width - utf8.RuneCountInString(s); pad > 0 { + return strings.Repeat(" ", pad) + s + } + return s +} + +func lowerFirst(s string) string { + if s == "" { + return s + } + first, size := utf8.DecodeRuneInString(s) + return string(unicode.ToLower(first)) + s[size:] +} + +// currencySign — знак валюты для заголовка. Неизвестная валюта печатается своим +// кодом: выдумывать знак хуже, чем показать «USD». +func currencySign(code string) string { + switch strings.ToUpper(strings.TrimSpace(code)) { + case "RUB", "": + return "₽" + case "THB": + return "฿" + case "USD": + return "$" + case "EUR": + return "€" + } + return strings.ToUpper(code) +} + // formatItems печатает разбивку: до topN пунктов + свёртка остатка в «прочее». // Категории нормализуются по регистру (единый вид). func formatItems(items []CategorySpend, rubPerTHB float64, topN int) string { @@ -109,8 +439,11 @@ func parseAdviceLines(raw string) []string { if ln == "" { continue } - if len(ln) > 200 { - ln = ln[:200] + // Обрезка по РУНАМ, а не по байтам: в кириллице ln[:200] режет символ + // пополам, и в ответ уезжает «д» (видно на живом прогоне + // safe_to_spend). Длина считается в символах — она и имелась в виду. + if r := []rune(ln); len(r) > adviceLineRunes { + ln = string(r[:adviceLineRunes]) } out = append(out, ln) if len(out) >= 4 { @@ -119,3 +452,151 @@ func parseAdviceLines(raw string) []string { } return out } + +// Ширина колонок моноблока ПОКАЗА конвертов. Колонок здесь три: имя и два +// числа — потрачено и осталось. Сумма 14+9+9 = 32, тот же порог 36, что и у +// раскладки: pre в Telegram не переносит строки по словам. +// +// Имя ужато с 18 до 14 знаков именно ради второго числа: без «потрачено» +// оператор видит остаток, но не видит, с чего тот упал. +const ( + remLabelWidth = 14 + remSpentWidth = 9 + remLeftWidth = 9 + remTotalsWidth = remLabelWidth + remSpentWidth + remLeftWidth +) + +// DaysLeft — сколько дней периода ещё осталось, СЕГОДНЯШНИЙ включительно. +// +// Считается по календарным датам, а не по разнице таймстампов: «осталось дней» +// — свойство календаря, и в 23:00 последнего дня их всё ещё один, а не ноль. +// +// Ниже единицы не опускается никогда. Это не косметика, а защита знаменателя: +// день после конца периода дал бы ноль, и дневной лимит стал бы +Inf. +func DaysLeft(now, periodEnd time.Time) int { + d := int(dayStart(periodEnd).Sub(dayStart(now)).Hours()/24) + 1 + if d < 1 { + d = 1 + } + return d +} + +// formatShareRemaining печатает ТЕКУЩИЕ конверты в том же формате, что и +// раскладка прихода (simpleAI-faeq.11), но с остатками (simpleAI-faeq.12). +// +// Форматтер только печатает готовые числа — не считает: иначе у остатка стало +// бы два источника, один из которых вёрстка. Единственное исключение — +// дневной лимит: он производная от уже посчитанных остатков и числа дней, и +// считается штатными DailyLimit/FlexibleRemainingTHB, а не арифметикой здесь. +// +// Что принципиально, помимо вёрстки: +// +// 1. Колонки две — потрачено и осталось. Один остаток отвечает «сколько ещё +// можно», но не отвечает «с чего он упал»; оператор не должен вычитать +// лимит из остатка в уме. +// 2. Пробитый конверт печатается МИНУСОМ, а не эмодзи: внутри pre эмодзи +// шириной ≈2 знака и рендерится по-разному на iOS/Android/Desktop — +// колонка едет. Названия пробитых собираются в строку под блоком. +// 3. Дневной лимит делится на ОСТАВШИЕСЯ дни (DaysLeft), а не на длину +// периода. Это и есть смысл показа: потратил сегодня 2 000 — завтра планка +// ниже, и оператор видит это числом, а не узнаёт в конце периода. +func formatShareRemaining(items []ShareRemaining, m Display, env *budget.Envelope, now time.Time) string { + daysLeft := DaysLeft(now, env.PeriodEnd) + var b strings.Builder + + // Шапка обычным текстом: период, сколько его осталось, курс. Курс + // показывается всегда, любой валютой показа — по нему оператор сверяет + // числа с тем, что видит в банке. + fmt.Fprintf(&b, "%s — %s · осталось %d %s\n", + env.PeriodStart.Format("02.01"), env.PeriodEnd.Format("02.01"), daysLeft, pluralDays(daysLeft)) + fmt.Fprintf(&b, "Курс %s ₽/฿ на %s\n", decimalComma(m.RubPerTHB), now.Format("02.01")) + + b.WriteString("\n**Что осталось**\n```\n") + fmt.Fprintf(&b, "%s%s%s\n", + padRight("", remLabelWidth), padLeft("потрачено", remSpentWidth), padLeft("осталось", remLeftWidth)) + + rows := remainingRows(items, m) + var spentTotal, leftTotal int + for _, r := range rows { + // Итог складывается из НАПЕЧАТАННЫХ чисел, а не из исходных float: + // иначе колонка из округлённых строк не сошлась бы в свой же итог + // (9 193,55 печатается как 9 194). + spentTotal += r.spent + leftTotal += r.left + fmt.Fprintf(&b, "%s%s%s\n", padRight(r.label, remLabelWidth), + padLeft(groupDigits(r.spent), remSpentWidth), padLeft(groupDigits(r.left), remLeftWidth)) + } + spentStr, leftStr := groupDigits(spentTotal), groupDigits(leftTotal) + fmt.Fprintf(&b, "%s%s%s\n", padRight("", remLabelWidth), + padLeft(strings.Repeat("-", utf8.RuneCountInString(spentStr)), remSpentWidth), + padLeft(strings.Repeat("-", utf8.RuneCountInString(leftStr)), remLeftWidth)) + fmt.Fprintf(&b, "%s%s%s\n```\n", padRight("", remLabelWidth), + padLeft(spentStr, remSpentWidth), padLeft(leftStr, remLeftWidth)) + + // Главное число — одно и внизу: по нему оператор действует сегодня. + fmt.Fprintf(&b, "\n**На день: %s**\n", m.Fmt(DailyLimit(FlexibleRemainingTHB(items), daysLeft))) + fmt.Fprintf(&b, "Осталось %d %s. %s", daysLeft, pluralDays(daysLeft), remainingScope(items)) + + if over := overspentNames(items); len(over) > 0 { + fmt.Fprintf(&b, "\n\n⚠️ Пробито: %s — дальше тратишь из других конвертов.", strings.Join(over, ", ")) + } + return b.String() +} + +// remainingRow — строка моноблока показа: имя и два уже целых числа в валюте +// показа. Округление делается ОДИН раз здесь, потому что складываться в итог +// обязаны именно напечатанные числа. +type remainingRow struct { + label string + spent int + left int +} + +// remainingRows раскладывает конверты по строкам. Порядок тот же, что в +// раскладке прихода: сначала регулярные платежи, затем гибкие, затем +// накопления — иначе два ответа об одних и тех же деньгах читались бы как два +// разных набора конвертов. +func remainingRows(items []ShareRemaining, m Display) []remainingRow { + rows := make([]remainingRow, 0, len(items)) + for _, kind := range []string{budget.ShareKindFixed, budget.ShareKindSpend, budget.ShareKindSave} { + for _, it := range items { + if it.Kind != kind { + continue + } + rows = append(rows, remainingRow{ + label: shareLabelWidth(it.Name, remLabelWidth), + spent: roundInt(m.Amount(it.SpentTHB)), + left: roundInt(m.Amount(it.Remaining)), + }) + } + } + return rows +} + +// overspentNames — имена пробитых конвертов, в порядке показа. +func overspentNames(items []ShareRemaining) []string { + out := make([]string, 0, len(items)) + for _, kind := range []string{budget.ShareKindFixed, budget.ShareKindSpend, budget.ShareKindSave} { + for _, it := range items { + if it.Kind == kind && it.Overspent() { + out = append(out, normalizeLabel(it.Name)) + } + } + } + return out +} + +// remainingScope — предложение под дневным лимитом: что входит в планку, а что +// уже отложено. Без него число «338 ฿» читается как «всё, что у меня есть». +func remainingScope(items []ShareRemaining) string { + var flexible, fixed []string + for _, it := range items { + switch it.Kind { + case budget.ShareKindSpend: + flexible = append(flexible, shareLabel(it.Name)) + case budget.ShareKindFixed: + fixed = append(fixed, shareLabel(it.Name)) + } + } + return scopeSentence(flexible, fixed) +} diff --git a/internal/skills/safetospend/format_envelope_test.go b/internal/skills/safetospend/format_envelope_test.go new file mode 100644 index 0000000..a5f37d0 --- /dev/null +++ b/internal/skills/safetospend/format_envelope_test.go @@ -0,0 +1,228 @@ +package safetospend + +import ( + "strings" + "testing" + "time" + "unicode/utf8" + + "simpleAI/internal/budget" +) + +// Эталон формата раскладки, утверждённый оператором 2026-08-24 (simpleAI-faeq.11). +// +// Тест держит не «примерно похоже», а посимвольное совпадение: формат — это и +// есть предмет задачи. Разъехавшаяся на один пробел колонка в моноблоке видна +// оператору сразу, а тесту «содержит подстроку» — нет. + +func due(t *testing.T, s string) *time.Time { + t.Helper() + d, err := time.Parse("2006-01-02", s) + if err != nil { + t.Fatalf("дата %q: %v", s, err) + } + return &d +} + +// referenceReply — данные оператора: приход 127 000 ₽ по курсу 3,1 ₽/฿ на +// период 24.08–06.09, четыре регулярных платежа и пять гибких конвертов. +func referenceReply(t *testing.T) EnvelopeReply { + t.Helper() + shares := []budget.EnvelopeShare{ + {Name: "аренда", Kind: budget.ShareKindFixed, Allocated: 18000, DueDate: due(t, "2026-09-10")}, + {Name: "Кредит потребительский Сбербанк", Kind: budget.ShareKindFixed, Allocated: 9193.55, DueDate: due(t, "2026-08-27")}, + {Name: "Ежемесячный платеж 3000р", Kind: budget.ShareKindFixed, Allocated: 967.74, DueDate: due(t, "2026-09-01")}, + {Name: "подписка Клауд личная", Kind: budget.ShareKindFixed, Allocated: 548.39, DueDate: due(t, "2026-09-10")}, + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 5400}, + {Name: "Транспорт", Kind: budget.ShareKindSpend, Allocated: 1700}, + {Name: "Здоровье", Kind: budget.ShareKindSpend, Allocated: 1700}, + {Name: "Развлечения", Kind: budget.ShareKindSpend, Allocated: 1400}, + {Name: budget.FallbackShareName, Kind: budget.ShareKindSpend, Allocated: 1200}, + {Name: savingsShareName, Kind: budget.ShareKindSave, Allocated: 858.06}, + } + for i := range shares { + shares[i].Position = i + shares[i].Source = budget.ShareSourceAuto + } + return EnvelopeReply{ + Plan: EnvelopePlan{Result: Result{IncomeTHB: 40967.74}, Shares: shares}, + RubPerTHB: 3.1, + Display: NewDisplay("THB", 3.1), + From: time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 9, 6, 0, 0, 0, 0, time.UTC), + IncomeAmount: 127000, + IncomeCurrency: "RUB", + } +} + +const referenceEnvelopeText = "24.08 — 06.09 · 14 дней\n" + + "Пришло 127 000 ₽ · 40 968 ฿\n" + + "Курс 3,1 ₽/฿ на 24.08\n" + + "\n" + + "**Куда уйдут**\n" + + "```\n" + + "Аренда 10.09 18 000\n" + + "Кредит 27.08 9 194\n" + + "Ежемесячный платеж 01.09 968\n" + + "Подписка Клауд 10.09 548\n" + + "Еда 5 400\n" + + "Транспорт 1 700\n" + + "Здоровье 1 700\n" + + "Развлечения 1 400\n" + + "Прочее 1 200\n" + + "Накопления 858\n" + + " ------\n" + + " 40 968\n" + + "```\n" + + "\n" + + "**На день: 814 ฿**\n" + + "Еда, транспорт, здоровье, развлечения, прочее. Аренда, Кредит, Ежемесячный платеж, Подписка Клауд — уже отложены." + +func TestFormatEnvelopePlan_MatchesApprovedReference(t *testing.T) { + got := FormatEnvelopePlan(referenceReply(t)) + if got != referenceEnvelopeText { + t.Errorf("формат разошёлся с эталоном оператора.\n--- получено ---\n%s\n--- ожидалось ---\n%s", got, referenceEnvelopeText) + } +} + +// Колонка в моноблоке не имеет права уезжать в горизонтальный скролл: pre в +// Telegram не переносит строки по словам. Порог 36 — из ресёрча вёрстки. +func TestFormatEnvelopePlan_MonoBlockWidth(t *testing.T) { + got := FormatEnvelopePlan(referenceReply(t)) + inPre := false + for _, line := range strings.Split(got, "\n") { + if line == "```" { + inPre = !inPre + continue + } + if !inPre { + continue + } + if w := utf8.RuneCountInString(line); w > 36 { + t.Errorf("строка моноблока шире 36 знаков (%d): %q", w, line) + } + for _, r := range line { + if r > 0x2000 && r != '฿' && r != '₽' && r != '—' { + t.Errorf("нетекстовый символ %q внутри pre ломает выравнивание: %q", r, line) + } + } + } +} + +// Итог обязан сходиться с приходом до бата: колонка складывается ровно в ту +// сумму, которую оператор увидел в шапке. +func TestFormatEnvelopePlan_ColumnSumsToIncome(t *testing.T) { + got := FormatEnvelopePlan(referenceReply(t)) + lines := strings.Split(got, "\n") + + var sum int + var total int + seenSeparator := false + for _, line := range lines { + if strings.Contains(line, "------") { + seenSeparator = true + continue + } + n, ok := trailingAmount(line) + if !ok { + continue + } + if seenSeparator { + total = n + break + } + sum += n + } + if !seenSeparator { + t.Fatal("в моноблоке нет строки-итога") + } + if sum != total { + t.Errorf("колонка складывается в %d, а итог напечатан как %d", sum, total) + } + if total != 40968 { + t.Errorf("итог %d не сходится с приходом 40 968 ฿", total) + } +} + +// trailingAmount вытаскивает число из хвоста строки моноблока («18 000» → 18000). +func trailingAmount(line string) (int, bool) { + fields := strings.Fields(line) + if len(fields) == 0 { + return 0, false + } + var digits []rune + for i := len(fields) - 1; i >= 0; i-- { + f := fields[i] + allDigits := f != "" + for _, r := range f { + if r < '0' || r > '9' { + allDigits = false + } + } + if !allDigits { + break + } + digits = append([]rune(f), digits...) + } + if len(digits) == 0 { + return 0, false + } + n := 0 + for _, r := range digits { + n = n*10 + int(r-'0') + } + return n, true +} + +// Дневной лимит не зависит от прихода: он считается от гибких конвертов и +// оставшихся дней. Иначе при приходе в 10 рублей бот отвечал бы «на день 0». +func TestDailyLimit_IndependentOfIncome(t *testing.T) { + shares := []budget.EnvelopeShare{ + {Name: "Аренда", Kind: budget.ShareKindFixed, Allocated: 18000}, + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 5400, CarriedIn: 0}, + {Name: "Транспорт", Kind: budget.ShareKindSpend, Allocated: 1700}, + {Name: "Здоровье", Kind: budget.ShareKindSpend, Allocated: 1700}, + {Name: "Развлечения", Kind: budget.ShareKindSpend, Allocated: 1400}, + {Name: budget.FallbackShareName, Kind: budget.ShareKindSpend, Allocated: 1200}, + {Name: savingsShareName, Kind: budget.ShareKindSave, Allocated: 858}, + } + if got := FlexibleTHB(shares); !eq(got, 11400) { + t.Fatalf("числитель дневного лимита = %.2f, ожидалось 11400 (только гибкие доли)", got) + } + if got := DailyLimit(FlexibleTHB(shares), 14); got < 814 || got >= 815 { + t.Errorf("дневной лимит = %.2f, ожидалось ~814", got) + } +} + +// Лимит пересчитывается от ОСТАТКА: потратил 2000 в первый день — планка на +// остаток дней падает. +func TestDailyLimit_FallsAfterSpending(t *testing.T) { + items := []ShareRemaining{ + {Name: "Аренда", Kind: budget.ShareKindFixed, Remaining: 18000}, + {Name: "Еда", Kind: budget.ShareKindSpend, Remaining: 3400}, + {Name: "Транспорт", Kind: budget.ShareKindSpend, Remaining: 1700}, + {Name: "Здоровье", Kind: budget.ShareKindSpend, Remaining: 1700}, + {Name: "Развлечения", Kind: budget.ShareKindSpend, Remaining: 1400}, + {Name: budget.FallbackShareName, Kind: budget.ShareKindSpend, Remaining: 1200}, + {Name: savingsShareName, Kind: budget.ShareKindSave, Remaining: 858}, + } + before := DailyLimit(11400, 14) + after := DailyLimit(FlexibleRemainingTHB(items), 13) + if after >= before { + t.Errorf("после траты 2000 планка не упала: было %.2f, стало %.2f", before, after) + } + if want := 9400.0 / 13.0; !eq(after, want) { + t.Errorf("дневной лимит после траты = %.2f, ожидалось %.2f (остаток гибких / оставшиеся дни)", after, want) + } +} + +// Нулевой приход не ломает раскладку: конверты наполнены прошлым переносом, и +// дневной лимит по-прежнему считается. +func TestDailyLimit_WorksOnZeroIncome(t *testing.T) { + shares := []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 0, CarriedIn: 2800}, + } + if got := DailyLimit(FlexibleTHB(shares), 14); !eq(got, 200) { + t.Errorf("при нулевом приходе дневной лимит = %.2f, ожидалось 200 (2800 переноса / 14 дней)", got) + } +} diff --git a/internal/skills/safetospend/format_share_remaining_test.go b/internal/skills/safetospend/format_share_remaining_test.go new file mode 100644 index 0000000..f45f3b7 --- /dev/null +++ b/internal/skills/safetospend/format_share_remaining_test.go @@ -0,0 +1,287 @@ +package safetospend + +import ( + "strings" + "testing" + "time" + "unicode/utf8" + + "simpleAI/internal/budget" +) + +// Показ ТЕКУЩИХ конвертов — тот же формат, что у раскладки прихода +// (simpleAI-faeq.11), но по каждому конверту видно потрачено / осталось, а +// дневной лимит пересчитан на ОСТАВШИЕСЯ дни (simpleAI-faeq.12). +// +// Тесты держат посимвольное совпадение по той же причине, что и у раскладки: +// формат — предмет задачи, а уехавшая на пробел колонка видна оператору сразу, +// но не тесту «содержит подстроку». + +func remainingEnv() *budget.Envelope { + return &budget.Envelope{ + PeriodStart: time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC), + PeriodEnd: time.Date(2026, 9, 6, 0, 0, 0, 0, time.UTC), + } +} + +// remainingReference — конверты оператора на второй день периода: часть еды +// потрачена, транспорт пробит на 200 ฿. +func remainingReference() []ShareRemaining { + mk := func(name, kind string, limit, spent float64) ShareRemaining { + return ShareRemaining{ + Name: name, Kind: kind, Source: budget.ShareSourceAuto, + Allocated: limit, LimitTHB: limit, SpentTHB: spent, Remaining: limit - spent, + } + } + return []ShareRemaining{ + mk("аренда", budget.ShareKindFixed, 18000, 0), + mk("Кредит потребительский Сбербанк", budget.ShareKindFixed, 9193.55, 0), + mk("Еда", budget.ShareKindSpend, 5400, 2000), + mk("Транспорт", budget.ShareKindSpend, 1700, 1900), + mk(budget.FallbackShareName, budget.ShareKindSpend, 1200, 0), + mk(savingsShareName, budget.ShareKindSave, 858.06, 0), + } +} + +// referenceNow — 25.08, второй день периода: до конца 06.09 остаётся 13 дней. +var referenceNow = time.Date(2026, 8, 25, 12, 0, 0, 0, time.UTC) + +const referenceRemainingText = "24.08 — 06.09 · осталось 13 дней\n" + + "Курс 3,1 ₽/฿ на 25.08\n" + + "\n" + + "**Что осталось**\n" + + "```\n" + + " потрачено осталось\n" + + "Аренда 0 18 000\n" + + "Кредит 0 9 194\n" + + "Еда 2 000 3 400\n" + + "Транспорт 1 900 -200\n" + + "Прочее 0 1 200\n" + + "Накопления 0 858\n" + + " ----- ------\n" + + " 3 900 32 452\n" + + "```\n" + + "\n" + + "**На день: 338 ฿**\n" + + "Осталось 13 дней. Еда, транспорт, прочее. Аренда, Кредит — уже отложены.\n" + + "\n" + + "⚠️ Пробито: Транспорт — дальше тратишь из других конвертов." + +func TestFormatShareRemaining_MatchesApprovedFormat(t *testing.T) { + got := formatShareRemaining(remainingReference(), NewDisplay("THB", 3.1), remainingEnv(), referenceNow) + if got != referenceRemainingText { + t.Errorf("формат показа конвертов разошёлся с эталоном.\n--- получено ---\n%s\n--- ожидалось ---\n%s", got, referenceRemainingText) + } +} + +// Моноблок не имеет права уехать в горизонтальный скролл, и эмодзи внутри pre +// быть не должно: обе колонки цифр держатся только моноширинным шрифтом. +func TestFormatShareRemaining_MonoBlockWidth(t *testing.T) { + got := formatShareRemaining(remainingReference(), NewDisplay("THB", 3.1), remainingEnv(), referenceNow) + inPre := false + for _, line := range strings.Split(got, "\n") { + if line == "```" { + inPre = !inPre + continue + } + if !inPre { + continue + } + if w := utf8.RuneCountInString(line); w > 36 { + t.Errorf("строка моноблока шире 36 знаков (%d): %q", w, line) + } + for _, r := range line { + if r > 0x2000 && r != '฿' && r != '₽' && r != '—' { + t.Errorf("нетекстовый символ %q внутри pre ломает выравнивание: %q", r, line) + } + } + } +} + +// Итог сходится: обе колонки складываются ровно в напечатанные под ними итоги. +// Проверяется по НАПЕЧАТАННЫМ числам, а не по исходным float: расхождение +// возникает именно на округлении строк (9 193,55 → 9 194). +func TestFormatShareRemaining_ColumnsSumToTotals(t *testing.T) { + got := formatShareRemaining(remainingReference(), NewDisplay("THB", 3.1), remainingEnv(), referenceNow) + + spentSum, remSum, spentTotal, remTotal, ok := monoColumnSums(got) + if !ok { + t.Fatal("в моноблоке нет строки-итога") + } + if spentSum != spentTotal { + t.Errorf("колонка «потрачено» складывается в %d, а итог напечатан как %d", spentSum, spentTotal) + } + if remSum != remTotal { + t.Errorf("колонка «осталось» складывается в %d, а итог напечатан как %d", remSum, remTotal) + } + if spentTotal != 3900 { + t.Errorf("итог потрачено = %d, ожидалось 3900", spentTotal) + } + if remTotal != 32452 { + t.Errorf("итог осталось = %d, ожидалось 32452 (18000+9194+3400−200+1200+858)", remTotal) + } +} + +// monoColumnSums читает обе числовые колонки моноблока: суммы строк до +// разделителя и напечатанные итоги после него. +func monoColumnSums(out string) (spentSum, remSum, spentTotal, remTotal int, ok bool) { + inPre := false + afterSep := false + for _, line := range strings.Split(out, "\n") { + if line == "```" { + inPre = !inPre + continue + } + if !inPre { + continue + } + if strings.Contains(line, "---") { + afterSep = true + continue + } + s, r, parsed := parseTwoAmounts(line) + if !parsed { + continue + } + if afterSep { + return spentSum, remSum, s, r, true + } + spentSum += s + remSum += r + } + return 0, 0, 0, 0, false +} + +// parseTwoAmounts достаёт из строки моноблока два числа. Разбор идёт по +// ГРАНИЦАМ КОЛОНОК, а не по strings.Fields: разряды отделены пробелом, и +// «3 900 32 452» распалось бы на четыре поля, из которых не собрать два +// числа. Ширины колонок — те же константы, которыми строка и печаталась. +func parseTwoAmounts(line string) (int, int, bool) { + r := []rune(line) + if len(r) < remTotalsWidth { + return 0, 0, false + } + spent, ok1 := parseGrouped(string(r[remLabelWidth : remLabelWidth+remSpentWidth])) + left, ok2 := parseGrouped(string(r[remLabelWidth+remSpentWidth : remTotalsWidth])) + if !ok1 || !ok2 { + return 0, 0, false + } + return spent, left, true +} + +// parseGrouped читает число одной колонки: пробелы-разряды выкидываются, +// нецифровое содержимое (шапка «потрачено», линейка «-----») отвергается. +func parseGrouped(cell string) (int, bool) { + digits := strings.ReplaceAll(strings.TrimSpace(cell), " ", "") + neg := strings.HasPrefix(digits, "-") + digits = strings.TrimPrefix(digits, "-") + if digits == "" { + return 0, false + } + n := 0 + for _, r := range digits { + if r < '0' || r > '9' { + return 0, false + } + n = n*10 + int(r-'0') + } + if neg { + n = -n + } + return n, true +} + +// Ключевое требование simpleAI-faeq.12: дневной лимит делится на ОСТАВШИЕСЯ +// дни, а не на длину периода. Купил продуктов на 2 000 — завтра планка ниже, и +// оператор видит это числом. +func TestFormatShareRemaining_DailyLimitUsesDaysLeft(t *testing.T) { + env := remainingEnv() + items := remainingReference() + + // Остаток гибких = 3400 − 200 + 1200 = 4400. + // 25.08 → до 06.09 включительно 13 дней: 4400/13 = 338. + got := formatShareRemaining(items, NewDisplay("THB", 3.1), env, referenceNow) + if !strings.Contains(got, "**На день: 338 ฿**") { + t.Errorf("на 25.08 ожидался лимит 338 ฿ (4400 / 13 дней), получено:\n%s", got) + } + // Через неделю остаток тот же, а дней меньше — планка ВЫШЕ той, что была бы + // при делении на весь период (4400/14 = 314). + later := time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC) // до 06.09 — 5 дней + got = formatShareRemaining(items, NewDisplay("THB", 3.1), env, later) + if !strings.Contains(got, "**На день: 880 ฿**") { + t.Errorf("на 02.09 ожидался лимит 880 ฿ (4400 / 5 дней), получено:\n%s", got) + } + if !strings.Contains(got, "осталось 5 дней") { + t.Errorf("в шапке нет числа оставшихся дней:\n%s", got) + } +} + +// Последний день периода: делим на 1, а не на длину периода. +func TestDaysLeft_LastDayIsOne(t *testing.T) { + end := time.Date(2026, 9, 6, 0, 0, 0, 0, time.UTC) + now := time.Date(2026, 9, 6, 23, 0, 0, 0, time.UTC) + if got := DaysLeft(now, end); got != 1 { + t.Errorf("в последний день периода осталось %d дней, ожидалось 1", got) + } + if got := DailyLimit(4400, DaysLeft(now, end)); !eq(got, 4400) { + t.Errorf("в последний день лимит = %.2f, ожидалось 4400 (весь остаток на один день)", got) + } +} + +// День ПОСЛЕ конца периода: делить на ноль (и на отрицательное) нельзя. +func TestDaysLeft_AfterPeriodEndNeverZero(t *testing.T) { + end := time.Date(2026, 9, 6, 0, 0, 0, 0, time.UTC) + for _, now := range []time.Time{ + time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC), + time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), + } { + if got := DaysLeft(now, end); got != 1 { + t.Errorf("после конца периода (%s) осталось %d дней, ожидалось 1", now.Format("02.01"), got) + } + } + out := formatShareRemaining(remainingReference(), NewDisplay("THB", 3.1), remainingEnv(), + time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC)) + if strings.Contains(out, "Inf") || strings.Contains(out, "NaN") { + t.Errorf("деление на ноль просочилось в ответ:\n%s", out) + } + if !strings.Contains(out, "**На день: 4 400 ฿**") { + t.Errorf("после конца периода ожидался лимит 4 400 ฿ (остаток / 1 день), получено:\n%s", out) + } +} + +// Пробитый конверт видно: минусом в колонке остатка и отдельной строкой снизу. +func TestFormatShareRemaining_OverspentIsVisible(t *testing.T) { + got := formatShareRemaining(remainingReference(), NewDisplay("THB", 3.1), remainingEnv(), referenceNow) + if !strings.Contains(got, "Транспорт 1 900 -200") { + t.Errorf("пробитый конверт не показан минусом в колонке остатка:\n%s", got) + } + if !strings.Contains(got, "⚠️ Пробито: Транспорт") { + t.Errorf("нет строки о пробитом конверте:\n%s", got) + } + + // Целый конверт такой строки не рождает. + whole := remainingReference() + whole[3].SpentTHB, whole[3].Remaining = 100, 1600 + if out := formatShareRemaining(whole, NewDisplay("THB", 3.1), remainingEnv(), referenceNow); strings.Contains(out, "Пробито") { + t.Errorf("ни один конверт не пробит, а предупреждение напечатано:\n%s", out) + } +} + +// Валюта показа — существующий механизм Display (simpleAI-faeq.9), не своя +// арифметика: в рублях все числа умножены на курс, знак — ₽. +func TestFormatShareRemaining_DisplayRUB(t *testing.T) { + got := formatShareRemaining(remainingReference(), NewDisplay("RUB", 2), remainingEnv(), referenceNow) + if !strings.Contains(got, "Еда 4 000 6 800") { + t.Errorf("суммы не переведены в рубли по курсу 2:\n%s", got) + } + if !strings.Contains(got, "**На день: 677 ₽**") { + t.Errorf("дневной лимит не в рублях (ожидалось 677 ₽ = 4400*2/13):\n%s", got) + } + // Строка курса печатает оба знака по определению («2,0 ₽/฿») — смотрим на + // суммы, а не на курс. + for _, line := range strings.Split(got, "\n") { + if !strings.HasPrefix(line, "Курс ") && strings.Contains(line, "฿") { + t.Errorf("в рублёвом показе остался батовый знак: %q", line) + } + } +} diff --git a/internal/skills/safetospend/remaining.go b/internal/skills/safetospend/remaining.go index ac6334d..7460c35 100644 --- a/internal/skills/safetospend/remaining.go +++ b/internal/skills/safetospend/remaining.go @@ -75,3 +75,66 @@ func formatRemaining(r RemainingResult, rubPerTHB float64, env *budget.Envelope) fmt.Fprintf(&b, " 💚 Свободно осталось: ~%.0f ₽\n", rub(r.RemainingTHB)) return b.String() } + +// runShares — режим «сколько осталось в конвертах» (ADR-008 §8). Скилл +// read-only: остаток считается из фактических транзакций и никуда не пишется. +func (s *SafeToSpendSkill) runShares(ctx context.Context, chatID int64, rates map[string]float64, m Display) (string, error) { + env, ok, err := s.store.GetActiveEnvelope(ctx, chatID) + if err != nil { + s.logger.WarnContext(ctx, "safe_to_spend: shares get active envelope", "err", err, "chat_id", chatID) + return "Не удалось получить конверт — попробуй позже.", nil + } + if !ok { + return "Активного конверта нет. Скажи «пришло X, разложи по конвертам» — заведу раскладку.", nil + } + + shares, err := s.store.ListShares(ctx, chatID, env.ID) + if err != nil { + s.logger.ErrorContext(ctx, "safe_to_spend: list shares", "err", err, "chat_id", chatID) + return "Временная ошибка — попробуй позже.", nil + } + if len(shares) == 0 { + // Конверт есть, но без раскладки — это старый режим ADR-007, а не + // поломка: честнее отдать общий остаток, чем пустой список конвертов. + return s.runRemaining(ctx, chatID, rates) + } + + // Факт — с начала конверта по сегодня, но не позже его конца (иначе в + // остаток уехали бы траты, к этому конверту не относящиеся) и не раньше его + // начала: у конверта, чей период стартует в будущем, окно факта пустое, а не + // отрицательное — иначе SpentByCategoryExcludingRecurring вернёт ошибку «to + // раньше from», и вместо статуса конвертов оператор увидит «временную + // ошибку». Та же обрезка с двух сторон, что в add_expense (share_warning.go). + spentTo := time.Now() + if spentTo.After(env.PeriodEnd) { + spentTo = env.PeriodEnd + } + if spentTo.Before(env.PeriodStart) { + spentTo = env.PeriodStart + } + rowsSpent, err := s.store.SpentByCategoryExcludingRecurring(ctx, env.PeriodStart, spentTo) + if err != nil { + s.logger.ErrorContext(ctx, "safe_to_spend: shares spent", "err", err, "chat_id", chatID) + return "Временная ошибка — попробуй позже.", nil + } + + items := computeShareRemaining(shares, rowsSpent, rates) + reply := formatShareRemaining(items, m, env, time.Now()) + s.logger.InfoContext(ctx, "safe_to_spend.shares", + "chat_id", chatID, "envelope_id", env.ID, "shares", len(items), "display", m.Code) + return reply, nil +} + +// sharesRequested — спрашивают именно про конверты/доли, а не про общий +// свободный остаток. Детект по словам, а не по параметру LLM: роутинг в +// golden-set (r044/r045) идёт на скилл без action, и модель имени режима не +// называет. +func sharesRequested(question string) bool { + q := strings.ToLower(question) + for _, w := range []string{"конверт", "доля", "доли", "долям", "по категориям", "осталось на "} { + if strings.Contains(q, w) { + return true + } + } + return false +} diff --git a/internal/skills/safetospend/remaining_test.go b/internal/skills/safetospend/remaining_test.go new file mode 100644 index 0000000..a6654c9 --- /dev/null +++ b/internal/skills/safetospend/remaining_test.go @@ -0,0 +1,90 @@ +package safetospend + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "simpleAI/internal/agent" + "simpleAI/internal/budget" +) + +// futureEnvelopeStore — конверт, чей период ещё не начался (оператор завёл его +// заранее). Факта за такой конверт быть не может. +// +// SpentByCategoryExcludingRecurring повторяет контракт настоящего стора: при +// to < from он возвращает ОШИБКУ, а не пустой список (internal/budget/ +// share_spent.go). Именно на этом ломался статус конвертов. +type futureEnvelopeStore struct { + fakeStore + env *budget.Envelope + shares []budget.EnvelopeShare +} + +func (s futureEnvelopeStore) GetActiveEnvelope(context.Context, int64) (*budget.Envelope, bool, error) { + return s.env, true, nil +} + +func (s futureEnvelopeStore) ListShares(context.Context, int64, uuid.UUID) ([]budget.EnvelopeShare, error) { + return s.shares, nil +} + +func (s futureEnvelopeStore) SpentByCategoryExcludingRecurring(_ context.Context, from, to time.Time) ([]budget.CategorySpentRow, error) { + if to.Before(from) { + return nil, fmt.Errorf("SpentByCategoryExcludingRecurring: to (%s) раньше from (%s)", to, from) + } + return nil, nil +} + +// Конверт с периодом в будущем обязан отдавать статус конвертов, а не +// «временную ошибку»: факта за него нет, но лимиты уже расписаны и оператор +// имеет право их увидеть. +func TestRunShares_FutureEnvelopeReturnsStatus(t *testing.T) { + start := time.Now().AddDate(0, 0, 7) + st := futureEnvelopeStore{ + env: &budget.Envelope{ + ID: uuid.New(), + IncomeAmount: 100000, + IncomeCurrency: "RUB", + PeriodStart: start, + PeriodEnd: start.AddDate(0, 0, 14), + }, + shares: []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 5000, Position: 0, + Categories: []budget.EnvelopeShareCategory{{CategoryName: "еда"}}}, + }, + } + + ctx := context.WithValue(context.Background(), agent.ChatIDKey{}, int64(1)) + skill := NewSafeToSpendSkill(st, fakeLLM{}, nil) + out, err := skill.Run(ctx, `{"question":"сколько осталось в конвертах?"}`) + if err != nil { + t.Fatalf("Run: %v", err) + } + if strings.Contains(out, "Временная ошибка") { + t.Fatalf("будущий конверт отдал ошибку вместо статуса: %q", out) + } + if !strings.Contains(out, "Еда") { + t.Fatalf("в статусе нет доли «Еда»: %q", out) + } +} + +// Тот же конверт, но через computeShareRemaining: факт нулевой, весь лимит на +// месте — обрезка окна факта не должна ничего «потратить». +func TestRunShares_FutureEnvelopeSpentIsZero(t *testing.T) { + shares := []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 5000, Position: 0, + Categories: []budget.EnvelopeShareCategory{{CategoryName: "еда"}}}, + } + items := computeShareRemaining(shares, nil, testRates) + if len(items) != 1 { + t.Fatalf("ожидали одну долю, got %d", len(items)) + } + if items[0].SpentTHB != 0 || items[0].Remaining != 5000 { + t.Fatalf("факт %.2f, остаток %.2f — ожидали 0 и 5000", items[0].SpentTHB, items[0].Remaining) + } +} diff --git a/internal/skills/safetospend/share_lookup.go b/internal/skills/safetospend/share_lookup.go new file mode 100644 index 0000000..7274f7b --- /dev/null +++ b/internal/skills/safetospend/share_lookup.go @@ -0,0 +1,47 @@ +package safetospend + +import ( + "github.com/google/uuid" + + "simpleAI/internal/budget" +) + +// ShareRemainingFor — остаток ТОЙ доли конверта, которой принадлежит категория +// траты (ADR-008 §6, §8). +// +// Экспортируется ради предупреждения в момент траты (budget.add_expense): +// формула остатка живёт в одном месте — computeShareRemaining, — и повторно её +// нигде не пишут. Здесь только выбор нужной строки из уже посчитанной раскладки. +// +// Матчинг тот же двухступенчатый ResolveShare: category_id → lower(name) → +// fallback-доля «прочее». Категория без своей доли попадает в «прочее», и +// предупреждение считается по ней — иначе часть факта пропала бы молча. +// +// Второе значение false, если долей нет вовсе или падать некуда (нет даже +// fallback-доли). +func ShareRemainingFor( + shares []budget.EnvelopeShare, + spentByCategory []budget.CategorySpentRow, + rates map[string]float64, + categoryID *uuid.UUID, + categoryName string, +) (ShareRemaining, bool) { + target := budget.ResolveShare(shares, categoryID, categoryName) + if target == nil { + return ShareRemaining{}, false + } + // ResolveShare возвращает указатель ВНУТРЬ shares, а computeShareRemaining + // сохраняет порядок входа — значит индекс доли и индекс её остатка совпадают. + // Сопоставление по индексу, а не по имени: имена долей приходят от раскладки + // и сравнивать их строками значило бы завести второй, расходящийся ключ. + out := computeShareRemaining(shares, spentByCategory, rates) + for i := range shares { + if &shares[i] == target { + if i < len(out) { + return out[i], true + } + break + } + } + return ShareRemaining{}, false +} diff --git a/internal/skills/safetospend/share_remaining_test.go b/internal/skills/safetospend/share_remaining_test.go new file mode 100644 index 0000000..9c9702a --- /dev/null +++ b/internal/skills/safetospend/share_remaining_test.go @@ -0,0 +1,141 @@ +package safetospend + +import ( + "math" + "testing" + + "github.com/google/uuid" + + "simpleAI/internal/budget" +) + +// Курс 1:1 — тест про формулу остатка, а не про конвертацию. +var testRates = map[string]float64{"THB": 1, "RUB": 1} + +func testShares() []budget.EnvelopeShare { + foodID := uuid.MustParse("11111111-1111-1111-1111-111111111111") + return []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 20000, Position: 0, + Categories: []budget.EnvelopeShareCategory{{CategoryID: &foodID, CategoryName: "еда"}}}, + {Name: "Транспорт", Kind: budget.ShareKindSpend, Allocated: 5000, Position: 1, + Categories: []budget.EnvelopeShareCategory{{CategoryName: "транспорт"}}}, + {Name: "прочее", Kind: budget.ShareKindSpend, Allocated: 3000, Position: 2}, + {Name: "Накопления", Kind: budget.ShareKindSave, Allocated: 10000, CarriedIn: 1500, Position: 3}, + } +} + +func byName(t *testing.T, items []ShareRemaining, name string) ShareRemaining { + t.Helper() + for _, it := range items { + if it.Name == name { + return it + } + } + t.Fatalf("доля %q не найдена в %+v", name, items) + return ShareRemaining{} +} + +func eq(a, b float64) bool { return math.Abs(a-b) < 0.005 } + +// Трата 3000 в категории доли «Еда» уменьшает её остаток ровно на 3000, +// соседние доли не двигаются. +func TestShareRemaining_SpendHitsOwnShareOnly(t *testing.T) { + shares := testShares() + base := computeShareRemaining(shares, nil, testRates) + got := computeShareRemaining(shares, []budget.CategorySpentRow{ + {CategoryName: "Еда", Currency: "THB", Amount: 3000}, + }, testRates) + + food := byName(t, got, "Еда") + if !eq(food.SpentTHB, 3000) { + t.Errorf("факт по «Еда» = %.2f, ожидалось 3000", food.SpentTHB) + } + if !eq(food.Remaining, 20000-3000) { + t.Errorf("остаток «Еда» = %.2f, ожидалось 17000", food.Remaining) + } + for _, name := range []string{"Транспорт", "прочее", "Накопления"} { + if b, g := byName(t, base, name), byName(t, got, name); !eq(b.Remaining, g.Remaining) { + t.Errorf("соседняя доля %q сдвинулась: %.2f → %.2f", name, b.Remaining, g.Remaining) + } + } +} + +// Матчинг по category_id работает даже при другом имени категории (ADR-008 §6). +func TestShareRemaining_MatchByCategoryID(t *testing.T) { + foodID := uuid.MustParse("11111111-1111-1111-1111-111111111111") + got := computeShareRemaining(testShares(), []budget.CategorySpentRow{ + {CategoryID: &foodID, CategoryName: "Продукты", Currency: "THB", Amount: 1000}, + }, testRates) + if f := byName(t, got, "Еда"); !eq(f.Remaining, 19000) { + t.Errorf("остаток «Еда» = %.2f, ожидалось 19000", f.Remaining) + } +} + +// Трата в «Переводы» — движение денег: не трогает ни одну долю (ADR-008 §4). +func TestShareRemaining_TransfersTouchNothing(t *testing.T) { + shares := testShares() + base := computeShareRemaining(shares, nil, testRates) + got := computeShareRemaining(shares, []budget.CategorySpentRow{ + {CategoryName: "Переводы", Currency: "THB", Amount: 50000}, + }, testRates) + for i := range base { + if !eq(base[i].Remaining, got[i].Remaining) { + t.Errorf("доля %q сдвинулась от траты в «Переводы»: %.2f → %.2f", + base[i].Name, base[i].Remaining, got[i].Remaining) + } + } +} + +// Транзакция с recurring_id не прожигает долю: такие траты уже вычтены как +// обязательства (ADR-008 §5). Инвариант держится на СТЫКЕ со стором, поэтому +// проверяется двумя тестами: здесь — что фильтрующий источник вообще +// подключён к режиму конвертов (см. TestRunShares_UsesRecurringFreeSource в +// skill_test.go), а на реальном SQL — +// budget.TestSpentByCategoryExcludingRecurring_Integration. +// +// Чистая функция recurring отличить не может и не должна: у CategorySpentRow +// такого поля нет — сюда доезжает уже очищенный факт. + +// Трата в переменной категории без своей доли уходит в приёмник «прочее». +func TestShareRemaining_UnknownCategoryGoesToFallback(t *testing.T) { + got := computeShareRemaining(testShares(), []budget.CategorySpentRow{ + {CategoryName: "Развлечения", Currency: "THB", Amount: 700}, + }, testRates) + if o := byName(t, got, "прочее"); !eq(o.Remaining, 3000-700) { + t.Errorf("остаток «прочее» = %.2f, ожидалось 2300", o.Remaining) + } + if f := byName(t, got, "Еда"); !eq(f.Remaining, 20000) { + t.Errorf("«Еда» не должна была двигаться: %.2f", f.Remaining) + } +} + +// Лимит доли = allocated + carried_in; пробитие видно флагом. +func TestShareRemaining_CarriedInAndOverspent(t *testing.T) { + shares := []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 1000, CarriedIn: 500, Position: 0, + Categories: []budget.EnvelopeShareCategory{{CategoryName: "еда"}}}, + {Name: "прочее", Kind: budget.ShareKindSpend, Position: 1}, + } + got := computeShareRemaining(shares, []budget.CategorySpentRow{ + {CategoryName: "Еда", Currency: "THB", Amount: 2000}, + }, testRates) + f := byName(t, got, "Еда") + if !eq(f.LimitTHB, 1500) { + t.Errorf("лимит = %.2f, ожидалось 1500 (allocated + carried_in)", f.LimitTHB) + } + if !eq(f.Remaining, -500) || !f.Overspent() { + t.Errorf("ожидалось пробитие −500, got %.2f overspent=%v", f.Remaining, f.Overspent()) + } +} + +// Валюта факта конвертируется по курсу; неизвестная валюта не раздувает факт. +func TestShareRemaining_CurrencyConversion(t *testing.T) { + rates := map[string]float64{"THB": 2.5, "RUB": 1} + got := computeShareRemaining(testShares(), []budget.CategorySpentRow{ + {CategoryName: "Еда", Currency: "RUB", Amount: 2500}, + {CategoryName: "Еда", Currency: "XXX", Amount: 999999}, + }, rates) + if f := byName(t, got, "Еда"); !eq(f.SpentTHB, 1000) { + t.Errorf("факт = %.2f THB, ожидалось 1000 (2500 RUB / 2.5)", f.SpentTHB) + } +} diff --git a/internal/skills/safetospend/skill.go b/internal/skills/safetospend/skill.go index 517b8a4..21e6b3c 100644 --- a/internal/skills/safetospend/skill.go +++ b/internal/skills/safetospend/skill.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/google/uuid" + "simpleAI/internal/agent" "simpleAI/internal/budget" "simpleAI/internal/core" @@ -23,6 +25,8 @@ type store interface { PlannedExpensesTHB(ctx context.Context, chatID int64, rates map[string]float64) (float64, int, error) ListPlannedExpenses(ctx context.Context, chatID int64) ([]budget.PlannedExpense, error) GetActiveEnvelope(ctx context.Context, chatID int64) (*budget.Envelope, bool, error) + ListShares(ctx context.Context, chatID int64, envelopeID uuid.UUID) ([]budget.EnvelopeShare, error) + SpentByCategoryExcludingRecurring(ctx context.Context, from, to time.Time) ([]budget.CategorySpentRow, error) } // SafeToSpendSkill — read-only reasoning skill (ADR-007 фаза H1). @@ -59,8 +63,12 @@ func (s *SafeToSpendSkill) Manifest() plugin.Manifest { "Триггеры EN: 'got X income, how much is free to spend?', 'received X, what's safe to spend?'. " + "РЕЖИМ ОСТАТКА (БЕЗ суммы): показывает, сколько свободно осталось из ранее сохранённого конверта, пересчитывая по фактическим тратам. " + "Триггеры RU: «сколько свободно осталось?», «сколько осталось из прихода?», «остаток по конверту», «сколько ещё могу потратить?», «сколько денег свободно сейчас?». " + + "РЕЖИМ КОНВЕРТОВ (БЕЗ суммы): показывает остаток и лимит по КАЖДОМУ категорийному конверту, пробитые видно сразу. Ничего не пишет. " + + "Триггеры RU: «сколько в конвертах», «сколько осталось в конвертах», «остаток конвертов», «сколько осталось на еду», «сколько осталось на транспорт». " + "НЕ используй для простой ЗАПИСИ дохода без вопроса о свободных деньгах («пришло X», «запиши доход X», «получил зарплату X» без вопроса) → budget.add_income. " + "НЕ используй для СОХРАНЕНИЯ прихода («запомни приход X», «заведи конверт») → budget.start_envelope. " + + "НЕ используй для РАСКЛАДКИ прихода по конвертам («пришло X, разложи по конвертам», «разложи приход X по конвертам», «раскидай X по конвертам») → budget.start_envelope: раскладка ПИШЕТ конверт и его доли, а этот скилл только считает. " + + "Триггер «на что распределить X?» здесь — только про устный разбор без сохранения; как только сказано «по конвертам» — это budget.start_envelope. " + "НЕ используй для affordability конкретной покупки («хватит ли на телефон») → advisor.advice. " + "НЕ используй для обзора трат без прихода → advisor.analyze / budget.summary.", InputSchema: &plugin.Schema{ @@ -69,10 +77,11 @@ func (s *SafeToSpendSkill) Manifest() plugin.Manifest { JSON: map[string]any{ "type": "object", "properties": map[string]any{ - "amount": map[string]any{"type": "number", "description": "Сумма пришедшего дохода из сообщения. НЕ указывай в режиме остатка (вопрос без суммы)."}, - "currency": map[string]any{"type": "string", "description": "Валюта прихода: RUB (по умолчанию), THB, USD, EUR."}, - "period": map[string]any{"type": "string", "description": "Горизонт расчёта. По умолчанию (пусто) — ближайшие 2 недели (интервал между приходами). 'month' — до конца текущего месяца; 'YYYY-MM' — конкретный месяц."}, - "question": map[string]any{"type": "string", "description": "Исходный вопрос пользователя."}, + "amount": map[string]any{"type": "number", "description": "Сумма пришедшего дохода из сообщения. НЕ указывай в режиме остатка (вопрос без суммы)."}, + "currency": map[string]any{"type": "string", "description": "Валюта прихода: RUB (по умолчанию), THB, USD, EUR."}, + "period": map[string]any{"type": "string", "description": "Горизонт расчёта. По умолчанию (пусто) — ближайшие 2 недели (интервал между приходами). 'month' — до конца текущего месяца; 'YYYY-MM' — конкретный месяц."}, + "question": map[string]any{"type": "string", "description": "Исходный вопрос пользователя ДОСЛОВНО. Заполняй всегда: по нему различаются режим общего остатка и режим конвертов."}, + "display_currency": map[string]any{"type": "string", "description": "Валюта, в которой ПОКАЗАТЬ конверты: THB (по умолчанию, «в батах») или RUB («покажи конверты в рублях», «сколько это в рублях»). Не путать с currency — та про сумму прихода из сообщения."}, }, "required": []string{}, }, @@ -85,6 +94,9 @@ type input struct { Currency string `json:"currency,omitempty"` Period string `json:"period,omitempty"` Question string `json:"question,omitempty"` + // DisplayCurrency — валюта ПОКАЗА конвертов (не хранения: доли всегда в THB, + // ADR-008 §7). Пусто → разбор фразы, затем дефолт THB. + DisplayCurrency string `json:"display_currency,omitempty"` } func (s *SafeToSpendSkill) Run(ctx context.Context, raw string) (string, error) { @@ -108,8 +120,12 @@ func (s *SafeToSpendSkill) Run(ctx context.Context, raw string) (string, error) return "Не могу посчитать — обнови курс валют командой /rates.", nil } - // Без суммы: режим «сколько осталось» по активному конверту (ADR-007 T5). + // Без суммы: режим остатка. Спрашивают про конверты — раскладка по долям + // (ADR-008 §8), иначе общий свободный остаток (ADR-007 T5). if in.Amount <= 0 { + if sharesRequested(in.Question) { + return s.runShares(ctx, chatID, rates, displayFor(in, rates["THB"])) + } return s.runRemaining(ctx, chatID, rates) } @@ -146,6 +162,23 @@ func (s *SafeToSpendSkill) Run(ctx context.Context, raw string) (string, error) return reply, nil } +// display — валюта показа конвертов. Приоритет: явное поле от LLM → разбор +// фразы оператора → дефолт THB. +// +// Два источника, а не один, потому что у каждого своя дыра: поле модель +// заполняет не всегда, а фраза может валюты не содержать вовсе. Дефолт при +// этом один и жёсткий — баты (см. display.go). +func displayFor(in input, rubPerTHB float64) Display { + code := strings.ToUpper(strings.TrimSpace(in.DisplayCurrency)) + if code == "" { + code = ParseDisplayCurrency(in.Question) + } + if code == "" { + code = DefaultDisplayCurrency + } + return NewDisplay(code, rubPerTHB) +} + // plannedBreakdown — ручные плановые траты по пунктам (описание→THB) и их итог. // Отдельный источник от прогноза (budget_planned_expense vs история транзакций) — // пересечения нет: одна и та же запись не попадает в оба блока (ADR-007 §4). diff --git a/internal/skills/safetospend/skill_test.go b/internal/skills/safetospend/skill_test.go index 0033e22..fc19176 100644 --- a/internal/skills/safetospend/skill_test.go +++ b/internal/skills/safetospend/skill_test.go @@ -3,9 +3,12 @@ package safetospend import ( "context" "regexp" + "strings" "testing" "time" + "github.com/google/uuid" + "simpleAI/internal/agent" "simpleAI/internal/budget" ) @@ -35,6 +38,12 @@ func (fakeStore) ListPlannedExpenses(context.Context, int64) ([]budget.PlannedEx func (fakeStore) GetActiveEnvelope(context.Context, int64) (*budget.Envelope, bool, error) { return nil, false, nil } +func (fakeStore) ListShares(context.Context, int64, uuid.UUID) ([]budget.EnvelopeShare, error) { + return nil, nil +} +func (fakeStore) SpentByCategoryExcludingRecurring(context.Context, time.Time, time.Time) ([]budget.CategorySpentRow, error) { + return nil, nil +} // fakeLLM возвращает заданную строку (для проверки, что числа от неё не зависят). type fakeLLM struct{ resp string } @@ -99,3 +108,136 @@ func TestNoAmountNoEnvelope(t *testing.T) { func regexpContains(s, pat string) bool { return regexp.MustCompile(pat).MatchString(s) } + +// shareStore — конверт с раскладкой. Факт трат отдаётся ТОЛЬКО через +// SpentByCategoryExcludingRecurring; GetPeriodSnapshot подсовывает «грязный» +// факт с recurring-тратой, которого в остатке доли быть не должно. +type shareStore struct { + fakeStore + spentCalled bool +} + +func (s *shareStore) GetActiveEnvelope(context.Context, int64) (*budget.Envelope, bool, error) { + return &budget.Envelope{ + ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"), + IncomeAmount: 127000, + IncomeCurrency: "RUB", + PeriodStart: time.Now().AddDate(0, 0, -7), + PeriodEnd: time.Now().AddDate(0, 0, 7), + }, true, nil +} + +func (s *shareStore) ListShares(context.Context, int64, uuid.UUID) ([]budget.EnvelopeShare, error) { + return []budget.EnvelopeShare{ + {Name: "Еда", Kind: budget.ShareKindSpend, Allocated: 10000, Position: 0, + Categories: []budget.EnvelopeShareCategory{{CategoryName: "еда"}}}, + {Name: "прочее", Kind: budget.ShareKindSpend, Allocated: 2600, Position: 1}, + }, nil +} + +func (s *shareStore) SpentByCategoryExcludingRecurring(context.Context, time.Time, time.Time) ([]budget.CategorySpentRow, error) { + s.spentCalled = true + return []budget.CategorySpentRow{{CategoryName: "Еда", Currency: "THB", Amount: 1000}}, nil +} + +// Режим конвертов берёт факт из источника, который отсекает recurring, а не из +// SpentByCategory снапшота (тот recurring не отделяет — был бы двойной учёт, +// ADR-008 §5). Мутация «взять факт из снапшота» роняет обе проверки. +func TestRunShares_UsesRecurringFreeSource(t *testing.T) { + st := &shareStore{} + s := NewSafeToSpendSkill(st, fakeLLM{}, nil) + ctx := context.WithValue(context.Background(), agent.ChatIDKey{}, int64(1)) + + out, err := s.Run(ctx, `{"question":"сколько осталось в конвертах?"}`) + if err != nil { + t.Fatal(err) + } + if !st.spentCalled { + t.Fatal("режим конвертов не спросил факт у SpentByCategoryExcludingRecurring") + } + // 10000 − 1000 = 9000 ฿ (валюту не просили — печатаем батами). Снапшотный + // факт (100 THB по «Еда») в остаток попасть не должен. + if !regexpContains(out, `Еда\s+1 000\s+9 000`) { + t.Errorf("ожидался остаток «Еда» 9000 ฿ при потраченных 1000 ฿, got:\n%s", out) + } +} + +// Валюта конвертов по умолчанию — баты: ни одного рублёвого знака в ответе. +// Мутация «форматтер всегда печатает рубли» роняет этот тест. +func TestRunShares_DefaultCurrencyIsTHB(t *testing.T) { + s := NewSafeToSpendSkill(&shareStore{}, fakeLLM{}, nil) + ctx := context.WithValue(context.Background(), agent.ChatIDKey{}, int64(1)) + + out, err := s.Run(ctx, `{"question":"сколько осталось в конвертах?"}`) + if err != nil { + t.Fatal(err) + } + if strings.Contains(amountsOnly(out), "₽") { + t.Errorf("без просьбы о рублях ответ обязан быть в батах, got:\n%s", out) + } +} + +// «Покажи конверты в рублях» — те же доли рублями по курсу конверта: +// 9000 ฿ × 2.6 = 23400 ₽ из 26000 ₽. Проверяются оба канала валюты — явное +// поле от LLM и разбор самой фразы (поле модель заполняет не всегда). +func TestRunShares_DisplayRUB(t *testing.T) { + cases := map[string]string{ + "поле от LLM": `{"question":"покажи конверты","display_currency":"RUB"}`, + "фраза": `{"question":"покажи конверты в рублях"}`, + } + for name, in := range cases { + t.Run(name, func(t *testing.T) { + s := NewSafeToSpendSkill(&shareStore{}, fakeLLM{}, nil) + ctx := context.WithValue(context.Background(), agent.ChatIDKey{}, int64(1)) + out, err := s.Run(ctx, in) + if err != nil { + t.Fatal(err) + } + if !regexpContains(out, `Еда\s+2 600\s+23 400`) { + t.Errorf("ожидался остаток «Еда» 23400 ₽ при потраченных 2600 ₽, got:\n%s", out) + } + if strings.Contains(amountsOnly(out), "฿") { + t.Errorf("просили рубли, а в ответе баты:\n%s", out) + } + }) + } +} + +// «В батах» словами — тот же дефолт, но названный явно. +func TestRunShares_DisplayTHBByWords(t *testing.T) { + s := NewSafeToSpendSkill(&shareStore{}, fakeLLM{}, nil) + ctx := context.WithValue(context.Background(), agent.ChatIDKey{}, int64(1)) + out, err := s.Run(ctx, `{"question":"покажи конверты в батах"}`) + if err != nil { + t.Fatal(err) + } + if !regexpContains(out, `Еда\s+1 000\s+9 000`) { + t.Errorf("ожидался остаток «Еда» 9000 ฿ при потраченных 1000 ฿, got:\n%s", out) + } +} + +// amountsOnly отбрасывает строку курса: она печатает ОБА знака валют («3,1 ₽/฿») +// по определению, и проверка «в ответе нет чужого знака» должна смотреть на +// суммы, а не на курс. +func amountsOnly(out string) string { + var keep []string + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, "Курс ") { + continue + } + keep = append(keep, line) + } + return strings.Join(keep, "\n") +} + +// Вопрос без слова «конверт» ведёт в общий остаток, а не в раскладку. +func TestRunRemaining_NotHijackedBySharesMode(t *testing.T) { + s := NewSafeToSpendSkill(fakeStore{}, fakeLLM{}, nil) + out, err := s.Run(context.Background(), `{"question":"сколько свободно осталось?"}`) + if err != nil { + t.Fatal(err) + } + if !regexpContains(out, `Активного конверта нет`) { + t.Errorf("ожидался общий режим остатка, got: %q", out) + } +} diff --git a/internal/telegram/context.go b/internal/telegram/context.go index 5f9317a..405d132 100644 --- a/internal/telegram/context.go +++ b/internal/telegram/context.go @@ -11,6 +11,7 @@ import ( "time" "simpleAI/internal/core" + "simpleAI/internal/notify" "simpleAI/internal/plugin" ) @@ -34,6 +35,9 @@ type Context struct { RequestID string MediaDir string Registry *plugin.Registry + // EnvelopeReminders — хранилище расписания утренних конвертов. nil = команда + // «присылай конверты по утрам» не обрабатывается (уходит агенту как текст). + EnvelopeReminders notify.EnvelopeReminderStore } func (c *Context) ChatID() (int64, error) { diff --git a/internal/telegram/handlers.go b/internal/telegram/handlers.go index 1500b42..631365e 100644 --- a/internal/telegram/handlers.go +++ b/internal/telegram/handlers.go @@ -8,6 +8,7 @@ import ( "simpleAI/internal/constants" "simpleAI/internal/core" + "simpleAI/internal/notify" budgetskill "simpleAI/internal/skills/budget" ) @@ -92,6 +93,9 @@ func HandleDefault(ctx context.Context, tctx *Context) error { if strings.TrimSpace(incoming.Text) == "" { return tctx.Reply("Пустое сообщение. Напиши запрос.") } + if handled, err := handleEnvelopeSchedule(ctx, tctx, incoming.Text); handled { + return err + } if _, err := SaveIngestPayload(ctx, tctx.MediaDir, incoming, nil); err != nil { if tctx.Logger != nil { tctx.Logger.Error("failed to save ingest payload", "err", err) @@ -124,3 +128,32 @@ func HandleDefault(ctx context.Context, tctx *Context) error { } return tctx.Reply(reply) } + +// handleEnvelopeSchedule перехватывает «присылай конверты по утрам» / «не +// присылай конверты» до похода к LLM. +// +// Детерминированно и мимо агента: включение рассылки — настройка, а не ответ. +// Ошибка маршрутизации здесь молчаливая — оператор получил бы вежливый текст и +// думал, что пуш включён, пока месяц не приходило ни одного. +func handleEnvelopeSchedule(ctx context.Context, tctx *Context, text string) (bool, error) { + if tctx.EnvelopeReminders == nil { + return false, nil + } + cmd, ok := notify.ParseEnvelopeCommand(text) + if !ok { + return false, nil + } + chatID, err := tctx.ChatID() + if err != nil { + return false, nil + } + + reply, err := notify.ApplyEnvelopeCommand(ctx, tctx.EnvelopeReminders, chatID, cmd, notify.DefaultEnvelopeTimezone) + if err != nil { + if tctx.Logger != nil { + tctx.Logger.Error("envelope schedule failed", "chat_id", chatID, "err", err) + } + return true, tctx.Reply("Не удалось сохранить настройку утренних конвертов — попробуй ещё раз.") + } + return true, tctx.Reply(reply) +}