From 60fb75dc8a8bb7867d21df8ab8f7cfda673f496b Mon Sep 17 00:00:00 2001 From: Alex Ultra Date: Wed, 3 Jun 2026 11:56:47 +0200 Subject: [PATCH] feat(bank): opt-in automatic bank-sync confirmation cards (#settings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bank sync previously pushed a transaction confirmation card to the group on every cycle. Make all automatic cards opt-in per group. Decided defaults: - Off by default (migration 050 adds groups.bank_cards_enabled INTEGER NOT NULL DEFAULT 0). - Per-group (shared across members), toggled like default_currency — not per-user. - Prefill (Anthropic spend) is skipped when off — no point paying for prefill nobody sees. - No retroactive flood on enable: notifyOldTransactions only fires for tx inserted in the current cycle, so flipping the toggle on later never dumps backlog. When bank_cards_enabled is falsy, sync becomes balance/history-only: Phase 1 still inserts every transaction (debit -> pending, credit -> skipped_reversal), but all three automated push paths are gated off as a single block: 1. Phase 2 — preFillTransactions (AI prefill) 2. Phase 3 — per-transaction confirmation cards for today 3. notifyOldTransactions — old-tx summary card OTP prompts, the "Принято" ack, the 3-consecutive-failures alert, panel updates, and the user-initiated bank_show_old / bank_confirm / bank_edit callbacks are NOT gated — they stay operational. Settings UI: /settings now shows the bank-cards state (вкл/выкл) with an inline toggle button (callback_data "settings:bankcards"); the callback flips the flag via the repository and re-renders in place. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bot/commands/bank.confirm.test.ts | 1 + src/bot/commands/bank.test.ts | 1 + src/bot/commands/budget.test.ts | 1 + src/bot/commands/dev.test.ts | 2 + src/bot/commands/push.test.ts | 1 + src/bot/commands/repair.test.ts | 1 + src/bot/commands/settings.test.ts | 88 ++++++++++++- src/bot/commands/settings.ts | 73 +++++++++-- src/bot/commands/sync.test.ts | 1 + src/bot/guards.test.ts | 1 + src/bot/handlers/callback.handler.ts | 10 ++ src/bot/handlers/message.handler.test.ts | 1 + src/bot/services/expense-saver.test.ts | 1 + .../repositories/group.repository.test.ts | 18 +++ src/database/repositories/group.repository.ts | 6 +- src/database/schema.ts | 13 ++ src/database/types.ts | 3 + src/services/ai/tool-executor.test.ts | 5 + .../bank/old-transactions-actions.test.ts | 1 + src/services/bank/sync-service.test.ts | 117 ++++++++++++++++- src/services/bank/sync-service.ts | 119 ++++++++++-------- src/services/budget-manager.test.ts | 6 + src/services/feedback.test.ts | 2 + src/web/miniapp-api.test.ts | 1 + src/web/oauth-callback.test.ts | 1 + 25 files changed, 410 insertions(+), 64 deletions(-) diff --git a/src/bot/commands/bank.confirm.test.ts b/src/bot/commands/bank.confirm.test.ts index 59a53dd7..3d7d03fc 100644 --- a/src/bot/commands/bank.confirm.test.ts +++ b/src/bot/commands/bank.confirm.test.ts @@ -200,6 +200,7 @@ const group: Group = { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }; diff --git a/src/bot/commands/bank.test.ts b/src/bot/commands/bank.test.ts index 765705eb..764c6384 100644 --- a/src/bot/commands/bank.test.ts +++ b/src/bot/commands/bank.test.ts @@ -190,6 +190,7 @@ const group: Group = { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }; diff --git a/src/bot/commands/budget.test.ts b/src/bot/commands/budget.test.ts index 578cc01a..50717320 100644 --- a/src/bot/commands/budget.test.ts +++ b/src/bot/commands/budget.test.ts @@ -168,6 +168,7 @@ function fakeGroup(overrides: Partial = {}): GoogleConnect active_topic_id: null, oauth_client: 'current', bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', ...overrides, diff --git a/src/bot/commands/dev.test.ts b/src/bot/commands/dev.test.ts index 26012f19..20c5b6aa 100644 --- a/src/bot/commands/dev.test.ts +++ b/src/bot/commands/dev.test.ts @@ -64,6 +64,7 @@ const mockGroups = { active_topic_id: null, oauth_client: 'legacy', bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '2026-04-19T00:00:00Z', updated_at: '2026-04-19T00:00:00Z', })), @@ -166,6 +167,7 @@ const group: Group = { active_topic_id: null, oauth_client: 'legacy', bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '2026-04-19T00:00:00Z', updated_at: '2026-04-19T00:00:00Z', }; diff --git a/src/bot/commands/push.test.ts b/src/bot/commands/push.test.ts index 83e85c5c..215bc8dc 100644 --- a/src/bot/commands/push.test.ts +++ b/src/bot/commands/push.test.ts @@ -98,6 +98,7 @@ function fakeGroup(): GoogleConnectedGroup { active_topic_id: null, oauth_client: 'current', bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', } as GoogleConnectedGroup; diff --git a/src/bot/commands/repair.test.ts b/src/bot/commands/repair.test.ts index 9efaa8db..2a2ba6f1 100644 --- a/src/bot/commands/repair.test.ts +++ b/src/bot/commands/repair.test.ts @@ -43,6 +43,7 @@ function fakeGroup(id = 1): GoogleConnectedGroup { active_topic_id: null, oauth_client: 'current', bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', } as GoogleConnectedGroup; diff --git a/src/bot/commands/settings.test.ts b/src/bot/commands/settings.test.ts index 3709c200..fb72a245 100644 --- a/src/bot/commands/settings.test.ts +++ b/src/bot/commands/settings.test.ts @@ -28,9 +28,24 @@ mock.module('../../services/bank/telegram-sender', () => ({ deleteMessage: mock(() => Promise.resolve()), })); +// ── Database ────────────────────────────────────────────────────────────── + +const groupsFindByTelegramGroupIdMock = mock((_id: number): Group | null => null); +const groupsUpdateMock = mock((_id: number, _data: Partial): Group | null => null); +mock.module('../../database', () => ({ + database: { + groups: { + findByTelegramGroupId: groupsFindByTelegramGroupIdMock, + update: groupsUpdateMock, + }, + }, +})); + // ── Import after mocks ──────────────────────────────────────────────────── -const { handleSettingsCommand } = await import('./settings'); +const { handleSettingsCommand, handleSettingsBankCardsToggle, buildSettingsView } = await import( + './settings' +); // ── Fixtures ────────────────────────────────────────────────────────────── @@ -52,14 +67,26 @@ function fakeGroup(overrides: Partial = {}): Group { active_topic_id: null, oauth_client: 'current', bank_panel_summary_message_id: null, + bank_cards_enabled: 0, created_at: '', updated_at: '', ...overrides, } as Group; } +function fakeCallbackCtx(): Ctx['CallbackQuery'] { + return { + message: { chat: { id: -100, type: 'supergroup' } }, + from: { id: 1 }, + answerCallbackQuery: mock(() => Promise.resolve()), + editText: mock(() => Promise.resolve()), + } as unknown as Ctx['CallbackQuery']; +} + beforeEach(() => { sendMessageMock.mockReset().mockResolvedValue(null); + groupsFindByTelegramGroupIdMock.mockReset().mockReturnValue(null); + groupsUpdateMock.mockReset().mockReturnValue(null); logMock.error.mockReset(); logMock.warn.mockReset(); }); @@ -133,4 +160,63 @@ describe('/settings', () => { const errMsg = sendMessageMock.mock.calls[1]?.[0] as string; expect(errMsg).toContain('непредвиденная'); }); + + test('renders bank-cards state and a toggle button reflecting it', async () => { + await handleSettingsCommand(fakeCtx(), fakeGroup({ bank_cards_enabled: 0 })); + + const msg = sendMessageMock.mock.calls[0]?.[0] as string; + expect(msg).toContain('Карточки банковских транзакций: выкл'); + + const opts = sendMessageMock.mock.calls[0]?.[1] as { reply_markup?: unknown } | undefined; + expect(opts?.reply_markup).toBeDefined(); + const view = buildSettingsView(fakeGroup({ bank_cards_enabled: 0 })); + expect(view.text).toContain('выкл'); + expect(JSON.stringify(view.keyboard)).toContain('Включить карточки банка'); + + const onView = buildSettingsView(fakeGroup({ bank_cards_enabled: 1 })); + expect(onView.text).toContain('Карточки банковских транзакций: вкл'); + expect(JSON.stringify(onView.keyboard)).toContain('Выключить карточки банка'); + }); +}); + +describe('/settings bank-cards toggle', () => { + test('flips bank_cards_enabled from 0 to 1 and re-renders', async () => { + groupsFindByTelegramGroupIdMock.mockReturnValue(fakeGroup({ bank_cards_enabled: 0 })); + groupsUpdateMock.mockReturnValue(fakeGroup({ bank_cards_enabled: 1 })); + + const ctx = fakeCallbackCtx(); + await handleSettingsBankCardsToggle(ctx); + + expect(groupsUpdateMock).toHaveBeenCalledWith(-100, { bank_cards_enabled: 1 }); + const editText = ctx.editText as ReturnType; + const editedText = editText.mock.calls[0]?.[0] as string; + expect(editedText).toContain('Карточки банковских транзакций: вкл'); + expect(logMock.error).not.toHaveBeenCalled(); + }); + + test('flips bank_cards_enabled from 1 to 0 and re-renders', async () => { + groupsFindByTelegramGroupIdMock.mockReturnValue(fakeGroup({ bank_cards_enabled: 1 })); + groupsUpdateMock.mockReturnValue(fakeGroup({ bank_cards_enabled: 0 })); + + const ctx = fakeCallbackCtx(); + await handleSettingsBankCardsToggle(ctx); + + expect(groupsUpdateMock).toHaveBeenCalledWith(-100, { bank_cards_enabled: 0 }); + const editText = ctx.editText as ReturnType; + const editedText = editText.mock.calls[0]?.[0] as string; + expect(editedText).toContain('Карточки банковских транзакций: выкл'); + expect(logMock.error).not.toHaveBeenCalled(); + }); + + test('answers with error and does not update when group is missing', async () => { + groupsFindByTelegramGroupIdMock.mockReturnValue(null); + + const ctx = fakeCallbackCtx(); + await handleSettingsBankCardsToggle(ctx); + + expect(groupsUpdateMock).not.toHaveBeenCalled(); + const answer = ctx.answerCallbackQuery as ReturnType; + expect(answer.mock.calls[0]?.[0]).toMatchObject({ text: 'Группа не настроена' }); + expect(logMock.error).not.toHaveBeenCalled(); + }); }); diff --git a/src/bot/commands/settings.ts b/src/bot/commands/settings.ts index 7db1ccfd..b15662ac 100644 --- a/src/bot/commands/settings.ts +++ b/src/bot/commands/settings.ts @@ -1,4 +1,6 @@ -/** /settings and /reconnect command handlers — show current config and re-authorize Google OAuth */ +/** /settings command — shows current group config and a per-group bank-cards toggle */ +import { InlineKeyboard } from 'gramio'; +import { database } from '../../database'; import type { Group } from '../../database/types'; import { sendMessage } from '../../services/bank/telegram-sender'; import { createLogger } from '../../utils/logger.ts'; @@ -7,20 +9,77 @@ import type { Ctx } from '../types'; const logger = createLogger('cmd-settings'); +/** Callback data for the bank-cards toggle button (kept short for the 64-byte limit) */ +const SETTINGS_BANK_CARDS_CALLBACK = 'settings:bankcards'; + +/** + * Build the settings message text and keyboard for a group. + * Shared by the /settings command and its toggle callback so both render identically. + */ +export function buildSettingsView(group: Group): { text: string; keyboard: InlineKeyboard } { + const cardsOn = !!group.bank_cards_enabled; + + let text = '⚙️ Настройки группы:\n\n'; + text += `💱 Валюта по умолчанию: ${group.default_currency}\n`; + text += `💵 Включенные валюты: ${group.enabled_currencies.join(', ')}\n`; + text += `📊 Таблица: ${group.spreadsheet_id ? 'настроена' : 'не настроена'}\n`; + text += `🔔 Карточки банковских транзакций: ${cardsOn ? 'вкл' : 'выкл'}\n`; + + const keyboard = new InlineKeyboard().text( + cardsOn ? '🔕 Выключить карточки банка' : '🔔 Включить карточки банка', + SETTINGS_BANK_CARDS_CALLBACK, + ); + + return { text, keyboard }; +} + /** * /settings command handler */ export async function handleSettingsCommand(ctx: Ctx['Command'], group: Group): Promise { void ctx; try { - let message = '⚙️ Настройки группы:\n\n'; - message += `💱 Валюта по умолчанию: ${group.default_currency}\n`; - message += `💵 Включенные валюты: ${group.enabled_currencies.join(', ')}\n`; - message += `📊 Таблица: ${group.spreadsheet_id ? 'настроена' : 'не настроена'}\n`; - - await sendMessage(message); + const { text, keyboard } = buildSettingsView(group); + await sendMessage(text, { reply_markup: keyboard }); } catch (error) { logger.error({ err: error }, '[CMD] Error in /settings'); await sendMessage(formatErrorForUser(error)); } } + +/** + * Toggle the per-group bank_cards_enabled setting from the /settings keyboard, + * then re-render the settings view in place. + */ +export async function handleSettingsBankCardsToggle(ctx: Ctx['CallbackQuery']): Promise { + try { + const chatId = ctx.message?.chat?.id; + if (!chatId) { + await ctx.answerCallbackQuery({ text: 'Группа не настроена' }); + return; + } + + const group = database.groups.findByTelegramGroupId(chatId); + if (!group) { + await ctx.answerCallbackQuery({ text: 'Группа не настроена' }); + return; + } + + const next = group.bank_cards_enabled ? 0 : 1; + const updated = database.groups.update(group.telegram_group_id, { bank_cards_enabled: next }); + if (!updated) { + await ctx.answerCallbackQuery({ text: 'Не удалось сохранить' }); + return; + } + + await ctx.answerCallbackQuery({ + text: next ? '🔔 Карточки банка включены' : '🔕 Карточки банка выключены', + }); + + const { text, keyboard } = buildSettingsView(updated); + await ctx.editText(text, { reply_markup: keyboard }); + } catch (error) { + logger.error({ err: error }, '[CMD] Error toggling bank cards in /settings'); + await ctx.answerCallbackQuery({ text: 'Ошибка' }); + } +} diff --git a/src/bot/commands/sync.test.ts b/src/bot/commands/sync.test.ts index 2ba6b0a6..43356f6a 100644 --- a/src/bot/commands/sync.test.ts +++ b/src/bot/commands/sync.test.ts @@ -178,6 +178,7 @@ function fakeGroup(): GoogleConnectedGroup { oauth_client: 'current', active_topic_id: null, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, title: null, invite_link: null, custom_prompt: null, diff --git a/src/bot/guards.test.ts b/src/bot/guards.test.ts index 410718c9..1666644c 100644 --- a/src/bot/guards.test.ts +++ b/src/bot/guards.test.ts @@ -28,6 +28,7 @@ function makeGroup(id: number, telegramGroupId: number): Group { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '2024-01-01', updated_at: '2024-01-01', }; diff --git a/src/bot/handlers/callback.handler.ts b/src/bot/handlers/callback.handler.ts index 562a1d2a..add2325e 100644 --- a/src/bot/handlers/callback.handler.ts +++ b/src/bot/handlers/callback.handler.ts @@ -42,6 +42,7 @@ import { import { handleDevCallback } from '../commands/dev'; import { handleDisconnectCancel, handleDisconnectConfirm } from '../commands/disconnect'; import { cancelPendingFeedback } from '../commands/feedback'; +import { handleSettingsBankCardsToggle } from '../commands/settings'; import { createBudgetPromptKeyboard, createCategoriesListKeyboard } from '../keyboards'; import { saveExpenseToSheet, saveReceiptExpenses } from '../services/expense-saver'; import { getSheetErrorMessage } from '../services/sheet-errors'; @@ -107,6 +108,15 @@ export async function handleCallbackQuery( break; } + case 'settings': { + if (params[0] === 'bankcards') { + await handleSettingsBankCardsToggle(ctx); + } else { + await ctx.answerCallbackQuery({ text: 'Invalid parameters' }); + } + break; + } + case 'setup': { const setupAction = params.join(':'); await handleSetupChoiceCallback(ctx, setupAction); diff --git a/src/bot/handlers/message.handler.test.ts b/src/bot/handlers/message.handler.test.ts index b9553c7b..1c09c3b0 100644 --- a/src/bot/handlers/message.handler.test.ts +++ b/src/bot/handlers/message.handler.test.ts @@ -247,6 +247,7 @@ function makeGroup(overrides: Partial = {}): Group { custom_prompt: null, active_topic_id: null, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, oauth_client: 'current', created_at: '', updated_at: '', diff --git a/src/bot/services/expense-saver.test.ts b/src/bot/services/expense-saver.test.ts index 5edd2210..79171384 100644 --- a/src/bot/services/expense-saver.test.ts +++ b/src/bot/services/expense-saver.test.ts @@ -160,6 +160,7 @@ function makeGroup() { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '2024-01-01', updated_at: '2024-01-01', }; diff --git a/src/database/repositories/group.repository.test.ts b/src/database/repositories/group.repository.test.ts index 724c05ba..93a8d49f 100644 --- a/src/database/repositories/group.repository.test.ts +++ b/src/database/repositories/group.repository.test.ts @@ -60,6 +60,11 @@ describe('GroupRepository', () => { expect(group.active_topic_id).toBeNull(); }); + test('new group has bank_cards_enabled defaulting to 0 (off)', () => { + const group = repo.create({ telegram_group_id: 109 }); + expect(group.bank_cards_enabled).toBe(0); + }); + test('created_at and updated_at are populated', () => { const group = repo.create({ telegram_group_id: 107 }); expect(group.created_at).toBeTruthy(); @@ -162,6 +167,19 @@ describe('GroupRepository', () => { expect(updated?.active_topic_id).toBeNull(); }); + test('updates bank_cards_enabled to 1 (on)', () => { + repo.create({ telegram_group_id: 409 }); + const updated = repo.update(409, { bank_cards_enabled: 1 }); + expect(updated?.bank_cards_enabled).toBe(1); + }); + + test('flips bank_cards_enabled back to 0 (off)', () => { + repo.create({ telegram_group_id: 410 }); + repo.update(410, { bank_cards_enabled: 1 }); + const updated = repo.update(410, { bank_cards_enabled: 0 }); + expect(updated?.bank_cards_enabled).toBe(0); + }); + test('returns null for non-existent telegram_group_id', () => { const result = repo.update(999999, { spreadsheet_id: 'x' }); expect(result).toBeNull(); diff --git a/src/database/repositories/group.repository.ts b/src/database/repositories/group.repository.ts index 5c76c6c6..8df1f5a7 100644 --- a/src/database/repositories/group.repository.ts +++ b/src/database/repositories/group.repository.ts @@ -13,7 +13,7 @@ const GROUP_JOIN_SELECT = ` SELECT g.id, g.telegram_group_id, g.title, g.invite_link, g.google_refresh_token, g.default_currency, g.enabled_currencies, g.custom_prompt, - g.active_topic_id, g.bank_panel_summary_message_id, + g.active_topic_id, g.bank_panel_summary_message_id, g.bank_cards_enabled, g.oauth_client, g.created_at, g.updated_at, gs.spreadsheet_id FROM groups g @@ -112,6 +112,10 @@ export class GroupRepository { updates.push('bank_panel_summary_message_id = ?'); values.push(data.bank_panel_summary_message_id); } + if (data.bank_cards_enabled !== undefined) { + updates.push('bank_cards_enabled = ?'); + values.push(data.bank_cards_enabled); + } if (data.oauth_client !== undefined) { updates.push('oauth_client = ?'); values.push(data.oauth_client); diff --git a/src/database/schema.ts b/src/database/schema.ts index 48d25b07..2728ea05 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -1371,6 +1371,19 @@ export function runMigrations(db: Database): void { } }, }, + { + name: '050_add_bank_cards_enabled_to_groups', + up: () => { + const check = db.query<{ count: number }, []>(` + SELECT COUNT(*) as count FROM pragma_table_info('groups') + WHERE name = 'bank_cards_enabled' + `); + if (check.get()?.count === 0) { + db.exec(`ALTER TABLE groups ADD COLUMN bank_cards_enabled INTEGER NOT NULL DEFAULT 0`); + logger.info('✓ Added bank_cards_enabled to groups'); + } + }, + }, ]; // Check and run migrations diff --git a/src/database/types.ts b/src/database/types.ts index 7b74f946..7a051d4b 100644 --- a/src/database/types.ts +++ b/src/database/types.ts @@ -18,6 +18,8 @@ export interface Group { custom_prompt: string | null; active_topic_id: number | null; bank_panel_summary_message_id: number | null; + /** 0 = bank sync sends no automatic confirmation cards; 1 = cards enabled */ + bank_cards_enabled: number; oauth_client: OAuthClientType; created_at: string; updated_at: string; @@ -38,6 +40,7 @@ export interface UpdateGroupData { custom_prompt?: string | null; active_topic_id?: number | null; bank_panel_summary_message_id?: number | null; + bank_cards_enabled?: number; oauth_client?: OAuthClientType; } diff --git a/src/services/ai/tool-executor.test.ts b/src/services/ai/tool-executor.test.ts index ea8e9262..2ee8c1ec 100644 --- a/src/services/ai/tool-executor.test.ts +++ b/src/services/ai/tool-executor.test.ts @@ -44,6 +44,7 @@ const mockGroups = { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', })), @@ -301,6 +302,7 @@ function resetAllMocks() { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', }); @@ -1321,6 +1323,7 @@ describe('executeGetGroupSettings', () => { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', }); @@ -2319,6 +2322,7 @@ describe('get_technical_analysis', () => { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', }); @@ -2408,6 +2412,7 @@ describe('get_technical_analysis', () => { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', }); diff --git a/src/services/bank/old-transactions-actions.test.ts b/src/services/bank/old-transactions-actions.test.ts index 34c1f354..65c115e8 100644 --- a/src/services/bank/old-transactions-actions.test.ts +++ b/src/services/bank/old-transactions-actions.test.ts @@ -63,6 +63,7 @@ const GROUP: Group = { active_topic_id: 77, oauth_client: 'legacy', bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', }; diff --git a/src/services/bank/sync-service.test.ts b/src/services/bank/sync-service.test.ts index 1979b27c..73752804 100644 --- a/src/services/bank/sync-service.test.ts +++ b/src/services/bank/sync-service.test.ts @@ -182,6 +182,9 @@ function buildGroup(overrides: Partial = {}): Group { custom_prompt: null, active_topic_id: null, bank_panel_summary_message_id: null, + // On by default in fixtures: the existing suite asserts cards/prefill fire. + // The opt-out behaviour is covered by its own describe block with an explicit 0. + bank_cards_enabled: 1, oauth_client: 'current', created_at: '', updated_at: '', @@ -335,13 +338,16 @@ const todayStr = (): string => { return `${yyyy}-${mm}-${dd}`; }; -function seedConnection(overrides: Partial = {}): BankConnection { +function seedConnection( + overrides: Partial = {}, + groupOverrides: Partial = {}, +): BankConnection { const conn = buildConnection(overrides); store.connections.set(conn.id, conn); store.credentials.set(conn.id, JSON.stringify({ login: 'u', password: 'p' })); // Populate plugin state so cron sync path is allowed. store.pluginState.set(conn.id, new Map([['auth', '{}']])); - store.groups.set(conn.group_id, buildGroup({ id: conn.group_id })); + store.groups.set(conn.group_id, buildGroup({ id: conn.group_id, ...groupOverrides })); return conn; } @@ -817,3 +823,110 @@ describe('runSyncCycle — inactive connection', () => { expect(logMock.error).not.toHaveBeenCalled(); }); }); + +describe('runSyncCycle — bank_cards_enabled toggle', () => { + function findConfirmationCard() { + return sendMessageMock.mock.calls.find((c) => { + const opts = c[1] as { reply_markup?: { inline_keyboard?: unknown[][] } } | undefined; + const firstRow = opts?.reply_markup?.inline_keyboard?.[0] as + | Array<{ callback_data?: string }> + | undefined; + return firstRow?.[0]?.callback_data?.startsWith('bank_confirm:') ?? false; + }); + } + + function findOldTxSummaryCard() { + return sendMessageMock.mock.calls.find((c) => { + const opts = c[1] as { reply_markup?: { inline_keyboard?: unknown[][] } } | undefined; + const firstRow = opts?.reply_markup?.inline_keyboard?.[0] as + | Array<{ callback_data?: string }> + | undefined; + return firstRow?.[0]?.callback_data?.startsWith('bank_show_old:') ?? false; + }); + } + + it('cards disabled: inserts transactions but sends no cards and skips prefill', async () => { + const conn = seedConnection({}, { bank_cards_enabled: 0 }); + + const today = todayStr(); + scrapeImpl.fn = async () => ({ + accounts: [{ id: 'acc1', title: 'Main', balance: 100, instrument: 'EUR', type: 'checking' }], + transactions: [ + { + id: 'tx-today', + date: `${today}T12:00:00Z`, + sum: -25, + currency: 'EUR', + account: 'acc1', + merchant: 'TodayStore', + }, + { + id: 'tx-old', + date: '2020-01-15T12:00:00Z', + sum: -15, + currency: 'EUR', + account: 'acc1', + merchant: 'OldStore', + }, + ], + }); + + await triggerManualSync(conn.id); + + // Phase 1 still runs — both transactions inserted into DB. + expect(bankTxInsertIgnoreMock).toHaveBeenCalledTimes(2); + expect(store.transactions.length).toBe(2); + + // Phase 2 (AI prefill) skipped entirely — no Anthropic spend. + expect(prefillMock).not.toHaveBeenCalled(); + + // Phase 3 — no per-tx confirmation card. + expect(findConfirmationCard()).toBeUndefined(); + + // notifyOldTransactions — no summary card. + expect(findOldTxSummaryCard()).toBeUndefined(); + + // An info log notes cards are disabled for the group. + const disabledLog = logMock.info.mock.calls.find( + (c) => typeof c[1] === 'string' && c[1].includes('cards disabled'), + ); + expect(disabledLog).toBeTruthy(); + + // Success path — failures still reset, no error logs. + expect(logMock.error).not.toHaveBeenCalled(); + }); + + it('cards enabled: prefill runs, confirmation card and old-tx summary are sent', async () => { + const conn = seedConnection({}, { bank_cards_enabled: 1 }); + + const today = todayStr(); + scrapeImpl.fn = async () => ({ + accounts: [], + transactions: [ + { + id: 'tx-today', + date: `${today}T12:00:00Z`, + sum: -25, + currency: 'EUR', + account: 'acc1', + merchant: 'TodayStore', + }, + { + id: 'tx-old', + date: '2020-01-15T12:00:00Z', + sum: -15, + currency: 'EUR', + account: 'acc1', + merchant: 'OldStore', + }, + ], + }); + + await triggerManualSync(conn.id); + + expect(prefillMock).toHaveBeenCalledTimes(1); + expect(findConfirmationCard()).toBeTruthy(); + expect(findOldTxSummaryCard()).toBeTruthy(); + expect(logMock.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/bank/sync-service.ts b/src/services/bank/sync-service.ts index 6b638220..121371c3 100644 --- a/src/services/bank/sync-service.ts +++ b/src/services/bank/sync-service.ts @@ -376,70 +376,83 @@ async function runSyncCycle(connectionId: number, allowOtp = false): Promise a.account_id === inserted.account_id); - if (account?.is_excluded === 1) continue; - } + // Skip cards for transactions from excluded accounts + if (inserted.account_id) { + const accounts = database.bankAccounts.findByConnectionId(connectionId); + const account = accounts.find((a) => a.account_id === inserted.account_id); + if (account?.is_excluded === 1) continue; + } - // Large transaction: compare EUR equivalent to threshold - const amountInEur = convertAnyToEUR(inserted.amount, inserted.currency); - const isLarge = amountInEur >= env.LARGE_TX_THRESHOLD_EUR; + // Large transaction: compare EUR equivalent to threshold + const amountInEur = convertAnyToEUR(inserted.amount, inserted.currency); + const isLarge = amountInEur >= env.LARGE_TX_THRESHOLD_EUR; - const cardText = formatConfirmationCard( - inserted, - prefilled.category, - conn.display_name, - isLarge, - ); + const cardText = formatConfirmationCard( + inserted, + prefilled.category, + conn.display_name, + isLarge, + ); - const result = await withChatContext( - group.telegram_group_id, - conn.panel_message_thread_id, - () => - sendMessage(cardText, { - reply_markup: { - inline_keyboard: [ - [ - { text: '✅ Принять', callback_data: `bank_confirm:${inserted.id}` }, - { text: '✏️ Исправить', callback_data: `bank_edit:${inserted.id}` }, + const result = await withChatContext( + group.telegram_group_id, + conn.panel_message_thread_id, + () => + sendMessage(cardText, { + reply_markup: { + inline_keyboard: [ + [ + { text: '✅ Принять', callback_data: `bank_confirm:${inserted.id}` }, + { text: '✏️ Исправить', callback_data: `bank_edit:${inserted.id}` }, + ], ], - ], - }, - }), - ); + }, + }), + ); - if (result) { - database.bankTransactions.setTelegramMessageId(inserted.id, result.message_id); + if (result) { + database.bankTransactions.setTelegramMessageId(inserted.id, result.message_id); + } } - } - // Notify about old (non-today) transactions that were inserted this cycle - const oldTxsWithPrefill = newPendingTxs - .map((tx, i) => ({ tx, category: prefillResults[i]?.category ?? tx.prefill_category ?? '—' })) - .filter(({ tx }) => tx.date !== today); - if (oldTxsWithPrefill.length > 0) { - await notifyOldTransactions(oldTxsWithPrefill, conn, group); + // Notify about old (non-today) transactions that were inserted this cycle + const oldTxsWithPrefill = newPendingTxs + .map((tx, i) => ({ + tx, + category: prefillResults[i]?.category ?? tx.prefill_category ?? '—', + })) + .filter(({ tx }) => tx.date !== today); + if (oldTxsWithPrefill.length > 0) { + await notifyOldTransactions(oldTxsWithPrefill, conn, group); + } + } else if (newPendingTxs.length > 0) { + logger.info( + { connectionId, groupId: group.id, pending: newPendingTxs.length }, + 'Bank cards disabled for group — stored transactions without sending cards', + ); } // Success: reset failures diff --git a/src/services/budget-manager.test.ts b/src/services/budget-manager.test.ts index 5c9e78ca..ec1d458c 100644 --- a/src/services/budget-manager.test.ts +++ b/src/services/budget-manager.test.ts @@ -24,6 +24,7 @@ const mockFindGroupById = mock((): Group | null => ({ active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', })); @@ -103,6 +104,7 @@ function resetAll() { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', }); @@ -197,6 +199,7 @@ describe('BudgetManager.set', () => { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', }); @@ -274,6 +277,7 @@ describe('BudgetManager.set', () => { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', }); @@ -329,6 +333,7 @@ describe('BudgetManager.delete', () => { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', }); @@ -358,6 +363,7 @@ describe('BudgetManager.delete', () => { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', }); diff --git a/src/services/feedback.test.ts b/src/services/feedback.test.ts index 412b7528..a428f13e 100644 --- a/src/services/feedback.test.ts +++ b/src/services/feedback.test.ts @@ -72,6 +72,7 @@ describe('sendFeedback', () => { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', }); @@ -113,6 +114,7 @@ describe('sendFeedback', () => { active_topic_id: null, oauth_client: 'legacy' as const, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, created_at: '', updated_at: '', }); diff --git a/src/web/miniapp-api.test.ts b/src/web/miniapp-api.test.ts index 00093679..afed8fb4 100644 --- a/src/web/miniapp-api.test.ts +++ b/src/web/miniapp-api.test.ts @@ -69,6 +69,7 @@ function stubGroup(overrides?: Partial): Group { custom_prompt: null, active_topic_id: null, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, oauth_client: 'current', created_at: '2024-01-01', updated_at: '2024-01-01', diff --git a/src/web/oauth-callback.test.ts b/src/web/oauth-callback.test.ts index 631b9440..15f1c776 100644 --- a/src/web/oauth-callback.test.ts +++ b/src/web/oauth-callback.test.ts @@ -31,6 +31,7 @@ function stubGroup(overrides: Partial & { id: number }): Group { custom_prompt: null, active_topic_id: null, bank_panel_summary_message_id: null, + bank_cards_enabled: 1, oauth_client: 'current', created_at: '2024-01-01', updated_at: '2024-01-01',