вторник, 7 июля 2026 г.

Сімейний облік витрат за допомоги Телеграм

 Задався метою якимось чином зібрати та проаналізувати структуру сімейних витрат. Вся наша родина є клієнтами Приватбанку. На жаль, банк не дає можливість отримати інформацію про транзакцію через веб хуки чи API для приватних осіб. А переходити у Монобанк тільки заради їхнього API не дуже хочеться. Та й бути прив'язаним до конкретного банку це погана практика, плюс потрібно вносити витрати готівкою. Тому було за декілька годин підготовлене таке рішення. 

Будемо накопичувати витрати у такій ось гуглотаблиці:

ДатаСумаКатегоріяДжерело данихКористувачПримітки

Ну а потім засобами гуглотаблиці вже будемо проводити аналіз з графіками чи трендами.
Дані в таблицю можна вбити вручну, але ж це марудна справа, яка зведе нанівець усю ідею. Тому виникла ідея їх заносити через чат бота у Телеграмі.

Отож що потрібно і яка послідовність дій?
1. Створюємо гуглотаблицю з вказаними заголовками колонок і називаємо лист таблиці "Витрати".
2. Відкриваємо в таблиці пункт "Розширення" - "Apps Script" і прописуємо туди код нашого бота

const TOKEN = 'токен_бота';
const SHEET_ID = 'ід_таблиці';

const USERS_DB = {
  314639039: "Сергій",
  2105359407: "Леся",
  5188583639: "Семен"
};

function doPost(e) {
  try {
    const data = JSON.parse(e.postData.contents);
    if (!data.message || !data.message.text) return HtmlService.createHtmlOutput("OK");
   
    const chatId = data.message.chat.id;
    const text = data.message.text.trim().toLowerCase();
   
    let userLabel = USERS_DB[chatId];
    if (!userLabel) {
      const rawUrl = 'https://api.telegram.org/bot'+TOKEN+'/sendMessage';
      const cleanUrl = rawUrl.replace(/\s+/g, '');
      UrlFetchApp.fetch(cleanUrl, {
        method: "post",
        payload: JSON.stringify({ chat_id: chatId, text: "🔒 Доступ обмежено. Цей бот є приватним сімейним інструментом розробника." }),
        contentType: "application/json",
        muteHttpExceptions: true
      });
      return HtmlService.createHtmlOutput("OK");
    }
   
    const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName("Витрати");
   
    // ==========================================
    // 1. ОБРОБКА КОМАНД СТАТИСТИКИ
    // ==========================================
    if (text === "📅 за день" || text === "🗓️ за місяць" || text === "📊 разом" || text === "за день" || text === "за місяць" || text === "разом") {
      const stats = calculateStats(sheet, userLabel);
     
      let responseMessage = "";
      if (text.includes("за день")) {
        responseMessage = "📅 **За сьогодні :** " + stats.userDay.toFixed(0) + " грн";
      } else if (text.includes("за місяць")) {
        responseMessage = "🗓️ **За місяць :** " + stats.userMonth.toFixed(0) + " грн";
      } else if (text.includes("разом")) {
        responseMessage = "📊 **Спільний сімейний звіт:**\n" +
                          "• Ви за сьогодні: " + stats.userDay.toFixed(0) + " грн\n" +
                          "• Ви за місяць: " + stats.userMonth.toFixed(0) + " грн\n" +
                          "• **ЗАГАЛОМ СЬОГОДНІ**: " + stats.totalDay.toFixed(0) + " грн\n" +
                          "• **ЗАГАЛОМ ЗА МІСЯЦЬ**: " + stats.totalMonth.toFixed(0) + " грн";
      }
     
      sendTextWithKeyboard(chatId, responseMessage);
      return HtmlService.createHtmlOutput("OK");
    }
   
    // ==========================================
    // 2. ОБРОБКА КОМАНДИ ВИВЕДЕННЯ ОСТАННІХ 5 ВИТРАТ
    // ==========================================
    if (text === "🔍 останні 5" || text === "останні 5") {
      const rows = sheet.getDataRange().getValues();
      let lastTransactions = [];
     
      for (let i = rows.length - 1; i >= 1; i--) {
        if (rows[i] && rows[i][4].toString() === userLabel) {
          const rowDateRaw = rows[i][0];
          let formattedDate = rowDateRaw.toString();
         
          if (rowDateRaw instanceof Date) {
            formattedDate = rowDateRaw.toLocaleString("uk-UA", {timeZone: "Europe/Kyiv"});
          }
         
          const dateOnly = formattedDate.split(",")[0];
          const amount = parseFloat(rows[i][1]).toFixed(0);
          const category = rows[i][2];
          const description = rows[i][5] || "-";
         
          lastTransactions.push(dateOnly + " — **" + amount + " грн**\n└ _" + category + "_ (" + description + ")");
         
          if (lastTransactions.length === 5) break;
        }
      }
     
      let responseMessage = "🔍 **Ваші останні 5 витрат:**\n\n";
      if (lastTransactions.length === 0) {
        responseMessage += "У таблиці ще немає жодного вашого запису.";
      } else {
        responseMessage += lastTransactions.join("\n\n");
      }
     
      sendTextWithKeyboard(chatId, responseMessage);
      return HtmlService.createHtmlOutput("OK");
    }
   
    // ==========================================
    // 3. ОБРОБКА КОМАНДИ ВИДАЛЕННЯ ОСТАННЬОГО ОСОБИСТОГО ЗАПИСУ
    // ==========================================
    if (text === "🗑️ видалити останній запис" || text === "видалити останній запис") {
      const rows = sheet.getDataRange().getValues();
      let rowToDelete = -1;
     
      for (let i = rows.length - 1; i >= 1; i--) {
        if (rows[i] && rows[i][4].toString() === userLabel) {
          rowToDelete = i + 1;
          break;
        }
      }
     
      if (rowToDelete === -1) {
        sendTextWithKeyboard(chatId, "❌ У таблиці не знайдено жодного вашого запису для видалення!");
        return HtmlService.createHtmlOutput("OK");
      }
     
      const targetRowData = sheet.getRange(rowToDelete, 1, 1, 4).getValues();
      const deletedAmount = parseFloat(targetRowData[0][1]);
      const deletedCategory = targetRowData[0][2];
     
      sheet.deleteRow(rowToDelete);
     
      const stats = calculateStats(sheet, userLabel);
      const responseMessage = "🗑️ **Успішно видалено ваш останній запис:** " + deletedAmount.toFixed(0) + " грн (" + deletedCategory + ")\n\n" +
                              "📊 **Оновлений звіт для контролю:**\n" +
                              "• Ви за сьогодні: " + stats.userDay.toFixed(0) + " грн\n" +
                              "• Разом сьогодні: " + stats.totalDay.toFixed(0) + " грн";
     
      sendTextWithKeyboard(chatId, responseMessage);
      return HtmlService.createHtmlOutput("OK");
    }
   
    // ==========================================
    // 4. ОБРОБКА ВВЕДЕННЯ ВИТРАТ (ВИПРАВЛЕНО ПАРСИНГ)
    // ==========================================
    const parts = data.message.text.trim().split(/\s+/);
    const amount = parseFloat(parts[0]);
   
    if (isNaN(amount)) {
      sendTextWithKeyboard(chatId, "❌ Невірний формат.\n\n• Щоб записати витрату, продиктуйте: [Сума] [Категорія] [Опис]\n• Або натисніть кнопку статистики нижче 👇");
      return HtmlService.createHtmlOutput("OK");
    }
   
    // ВИПРАВЛЕННЯ: беремо саме елементи масиву як чистий текст
    const category = parts[1] || "Інше";
    const description = parts.slice(2).join(" ") || "-";
    const date = new Date().toLocaleString("uk-UA", {timeZone: "Europe/Kyiv"});
   
    sheet.appendRow([date, amount, category, "Телеграм", userLabel, description]);
   
    sendTextWithKeyboard(chatId, "✅ **Записано (" + userLabel + "):** " + amount.toFixed(0) + " грн в категорію \"" + category + "\"");
   
  } catch (error) {
    try {
      const dataError = JSON.parse(e.postData.contents);
      if (dataError.message) {
        const rawUrl = 'https://api.telegram.org/bot'+TOKEN+'/sendMessage';
        const cleanUrlErr = rawUrl.replace(/\s+/g, '');
        UrlFetchApp.fetch(cleanUrlErr, {
          method: "post",
          payload: JSON.stringify({ chat_id: dataError.message.chat.id, text: "⚠️ Помилка: " + error.toString() }),
          contentType: "application/json",
          muteHttpExceptions: true
        });
      }
    } catch (e) {}
  }
  return HtmlService.createHtmlOutput("OK");
}

function calculateStats(sheet, currentUser) {
  const rows = sheet.getDataRange().getValues();
  const result = { userDay: 0, userMonth: 0, totalDay: 0, totalMonth: 0 };
  if (rows.length <= 1) return result;
 
  const now = new Date();
  const todayStr = now.toLocaleDateString("uk-UA", {timeZone: "Europe/Kyiv"});
  const currentMonth = now.getMonth();
  const currentYear = now.getFullYear();
 
  for (let i = 1; i < rows.length; i++) {
    let rowDate = rows[i][0];
    const amount = parseFloat(rows[i][1]);
    const rowUser = rows[i][4] ? rows[i][4].toString() : "";
   
    if (isNaN(amount) || !rowDate) continue;
   
    let parsedDate;
    if (rowDate instanceof Date) {
      parsedDate = rowDate;
    } else {
      const cleanStr = rowDate.toString().split(",")[0].trim();
      const parts = cleanStr.split(".");
      if (parts.length === 3) {
        parsedDate = new Date(parseInt(parts[2], 10), parseInt(parts[1], 10) - 1, parseInt(parts[0], 10));
      }
    }
   
    if (parsedDate) {
      const rowDateStr = parsedDate.toLocaleDateString("uk-UA", {timeZone: "Europe/Kyiv"});
      const isToday = (rowDateStr === todayStr);
      const isCurrentMonth = (parsedDate.getMonth() === currentMonth && parsedDate.getFullYear() === currentYear);
     
      if (isToday) result.totalDay += amount;
      if (isCurrentMonth) result.totalMonth += amount;
     
      if (rowUser === currentUser) {
        if (isToday) result.userDay += amount;
        if (isCurrentMonth) result.userMonth += amount;
      }
    }
  }
 
  return result;
}

function sendTextWithKeyboard(chatId, text) {
  const rawUrl = 'https://api.telegram.org/bot'+TOKEN+'/sendMessage';
  const cleanUrl = rawUrl.replace(/\s+/g, '');
 
  const keyboard = {
    keyboard: [
      [{ text: "📅 За день" }, { text: "🗓️ За місяць" }],
      [{ text: "📊 Разом" }, { text: "🔍 Останні 5" }],
      [{ text: "🗑️ Видалити останній запис" }]
    ],
    resize_keyboard: true,
    one_time_keyboard: false
  };

  const payload = {
    chat_id: chatId,
    text: text,
    parse_mode: "Markdown",
    reply_markup: JSON.stringify(keyboard)
  };
 
  UrlFetchApp.fetch(cleanUrl, {
    method: "post",
    payload: JSON.stringify(payload),
    contentType: "application/json",
    muteHttpExceptions: true
  });
}

function runManualTest() {
  const mockEvent = {
    postData: {
      contents: JSON.stringify({
        message: {
          chat: { id: 2105339407 },
          text: "150 кава сільпо"
        }
      })
    }
  };
  doPost(mockEvent);
}

Замість ід_таблиці прописуємо реальний ідентифікатор, який беремо із адреси таблиці між https://docs.google.com/spreadsheets/d/ та /edit?gid=0#gid=0.

3. Створюємо нового Телеграм бота через супербота @BotFather командою /newbot . Копіюємо токен цього бота в наш скрипт замість токен_бота.

4. Взнаєм свій ідентифікатор та ідентифікатори членів родини через бота @userinfo3bot і прописуємо у масив USERS_DB . Також підставте ід будь-кого з родини у функцію runManualTest для ручного тестування роботи бота.

5. Зберігаємо скрипт бота і активуємо через "Ввести в дію" - "Нове введення в дію". Обов'язково вказуємо щоб бот виконувався від вашого імені (ваш e-mail) і доступ для всіх.

6. Проходимо авторизацію (натисніть Advanced -> Go to Untitled project (unsafe) -> Allow).

7. Скопіюйте URL-адресу вебзастосунку, яка з'явиться (вона закінчується на /exec).

8. Викличте в браузері https://api.telegram.org/botВАШ_ТОКЕН_БОТА/setWebhook?url=ВАША_АДРЕСА_СКРИПТА (замінивши токен і адресу на вже вам відомі) - ви маєте отримати json повідомлення що застосунок прив'язався до бота.

9. Поверніться в скрипт і запустіть вручну функцію runManualTest - у вашій таблиці має появитися перший запис.


Тепер або набирайте витрату вручну або наговорюйте через мікрофончик клавіатури у форматі: Сума Категорія Опис. Сума в цілих гривнях, категорія - одним словом, опис може складатись з декількох слів.