Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/bot/commands/bank.confirm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand Down
1 change: 1 addition & 0 deletions src/bot/commands/bank.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand Down
1 change: 1 addition & 0 deletions src/bot/commands/budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ function fakeGroup(overrides: Partial<GoogleConnectedGroup> = {}): GoogleConnect
active_topic_id: null,
oauth_client: 'current',
bank_panel_summary_message_id: null,
bank_cards_enabled: 1,
created_at: '',
updated_at: '',
...overrides,
Expand Down
2 changes: 2 additions & 0 deletions src/bot/commands/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})),
Expand Down Expand Up @@ -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',
};
Expand Down
1 change: 1 addition & 0 deletions src/bot/commands/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/bot/commands/repair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
88 changes: 87 additions & 1 deletion src/bot/commands/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): 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 ──────────────────────────────────────────────────────────────

Expand All @@ -52,14 +67,26 @@ function fakeGroup(overrides: Partial<Group> = {}): 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();
});
Expand Down Expand Up @@ -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<typeof mock>;
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<typeof mock>;
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<typeof mock>;
expect(answer.mock.calls[0]?.[0]).toMatchObject({ text: 'Группа не настроена' });
expect(logMock.error).not.toHaveBeenCalled();
});
});
73 changes: 66 additions & 7 deletions src/bot/commands/settings.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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> {
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<void> {
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: 'Ошибка' });
}
}
1 change: 1 addition & 0 deletions src/bot/commands/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/bot/guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand Down
10 changes: 10 additions & 0 deletions src/bot/handlers/callback.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/bot/handlers/message.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ function makeGroup(overrides: Partial<Group> = {}): 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: '',
Expand Down
1 change: 1 addition & 0 deletions src/bot/services/expense-saver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand Down
18 changes: 18 additions & 0 deletions src/database/repositories/group.repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 5 additions & 1 deletion src/database/repositories/group.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions src/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading