/** * BENDAHARA KELUARGA - BACKEND GOOGLE APPS SCRIPT * Timezone: Asia/Jakarta * Single Source of Truth: Google Sheets */ const TIMEZONE = "Asia/Jakarta"; /** * Setup Database Google Sheets */ function setupDatabase() { var ss = SpreadsheetApp.getActiveSpreadsheet(); // Sheet SETTINGS var sheetSettings = ss.getSheetByName("SETTINGS"); if (!sheetSettings) { sheetSettings = ss.insertSheet("SETTINGS"); sheetSettings.appendRow(["key", "value"]); sheetSettings.appendRow(["family_name", "Bendahara Keluarga"]); sheetSettings.appendRow(["currency", "IDR"]); sheetSettings.appendRow(["timezone", TIMEZONE]); sheetSettings.getRange("A1:B1").setFontWeight("bold").setBackground("#059669").setFontColor("#ffffff"); } // Sheet INCOME var sheetIncome = ss.getSheetByName("INCOME"); if (!sheetIncome) { sheetIncome = ss.insertSheet("INCOME"); sheetIncome.appendRow(["income_id", "date", "month", "category", "description", "amount", "created_at", "updated_at"]); sheetIncome.getRange("A1:H1").setFontWeight("bold").setBackground("#059669").setFontColor("#ffffff"); } // Sheet EXPENSES var sheetExpenses = ss.getSheetByName("EXPENSES"); if (!sheetExpenses) { sheetExpenses = ss.insertSheet("EXPENSES"); sheetExpenses.appendRow(["expense_id", "date", "month", "category", "description", "amount", "created_at", "updated_at"]); sheetExpenses.getRange("A1:H1").setFontWeight("bold").setBackground("#e11d48").setFontColor("#ffffff"); } } /** * Seed Demo Data */ function seedDemoData() { setupDatabase(); var ss = SpreadsheetApp.getActiveSpreadsheet(); var nowStr = Utilities.formatDate(new Date(), TIMEZONE, "yyyy-MM-dd"); var monthStr = Utilities.formatDate(new Date(), TIMEZONE, "yyyy-MM"); var isoNow = new Date().toISOString(); var sheetInc = ss.getSheetByName("INCOME"); if (sheetInc.getLastRow() <= 1) { sheetInc.appendRow(["INC-" + monthStr.replace("-","") + "-0001", nowStr, monthStr, "Gaji", "Gaji Bulanan Utama", 7000000, isoNow, isoNow]); } var sheetExp = ss.getSheetByName("EXPENSES"); if (sheetExp.getLastRow() <= 1) { sheetExp.appendRow(["EXP-" + monthStr.replace("-","") + "-0001", nowStr, monthStr, "Sayur & Lauk", "Belanja Mingguan Pasar", 1250000, isoNow, isoNow]); sheetExp.appendRow(["EXP-" + monthStr.replace("-","") + "-0002", nowStr, monthStr, "Listrik", "Token PLN", 450000, isoNow, isoNow]); sheetExp.appendRow(["EXP-" + monthStr.replace("-","") + "-0003", nowStr, monthStr, "Air", "Tagihan PDAM", 150000, isoNow, isoNow]); sheetExp.appendRow(["EXP-" + monthStr.replace("-","") + "-0004", nowStr, monthStr, "Internet", "IndiHome", 300000, isoNow, isoNow]); sheetExp.appendRow(["EXP-" + monthStr.replace("-","") + "-0005", nowStr, monthStr, "Transportasi", "Bensin Motor & Mobil", 350000, isoNow, isoNow]); sheetExp.appendRow(["EXP-" + monthStr.replace("-","") + "-0006", nowStr, monthStr, "Pendidikan Anak 1", "SPP Sekolah", 500000, isoNow, isoNow]); } } function doGet(e) { return ContentService.createTextOutput("Bendahara Keluarga Backend Operational API.") .setMimeType(ContentService.MimeType.TEXT); } function doPost(e) { var lock = LockService.getScriptLock(); lock.waitLock(10000); try { var data = JSON.parse(e.postData.contents); var action = data.action; var result = { status: "success" }; if (action === "getDashboardData") { result.data = getDashboardDataLogic(data.month); } else if (action === "getIncome") { result.data = getIncomeLogic(); } else if (action === "createIncome") { createIncomeLogic(data); } else if (action === "updateIncome") { updateIncomeLogic(data); } else if (action === "deleteIncome") { deleteIncomeLogic(data.income_id); } else if (action === "getExpenses") { result.data = getExpensesLogic(); } else if (action === "createExpense") { createExpenseLogic(data); } else if (action === "updateExpense") { updateExpenseLogic(data); } else if (action === "deleteExpense") { deleteExpenseLogic(data.expense_id); } else if (action === "getMonthlyReport") { result.data = getMonthlyReportLogic(data.month); } else if (action === "getSettings") { result.data = getSettingsLogic(); } else if (action === "updateSettings") { updateSettingsLogic(data); } else { throw new Error("Aksi tidak dikenal: " + action); } return ContentService.createTextOutput(JSON.stringify(result)) .setMimeType(ContentService.MimeType.JSON); } catch (err) { return ContentService.createTextOutput(JSON.stringify({ status: "error", message: err.message })) .setMimeType(ContentService.MimeType.JSON); } finally { lock.releaseLock(); } } function getDashboardDataLogic(month) { var ss = SpreadsheetApp.getActiveSpreadsheet(); var incSheet = ss.getSheetByName("INCOME"); var incData = incSheet ? incSheet.getDataRange().getValues() : []; var totalInc = 0; var filteredInc = []; for (var i = 1; i < incData.length; i++) { var r = incData[i]; var dStr = String(r[1]); var item = { income_id: r[0], date: dStr, month: r[2], category: r[3], description: r[4], amount: Number(r[5]), type: 'income' }; if (!month || dStr.startsWith(month)) { totalInc += item.amount; filteredInc.push(item); } } var expSheet = ss.getSheetByName("EXPENSES"); var expData = expSheet ? expSheet.getDataRange().getValues() : []; var totalExp = 0; var filteredExp = []; var maxExp = { category: "-", amount: 0 }; for (var j = 1; j < expData.length; j++) { var row = expData[j]; var dateStr = String(row[1]); var itm = { expense_id: row[0], date: dateStr, month: row[2], category: row[3], description: row[4], amount: Number(row[5]), type: 'expense' }; if (!month || dateStr.startsWith(month)) { totalExp += itm.amount; filteredExp.push(itm); if (itm.amount > maxExp.amount) { maxExp = { category: itm.category, amount: itm.amount }; } } } var combined = filteredInc.concat(filteredExp); combined.sort(function(a, b) { return new Date(b.date) - new Date(a.date); }); return { month: month, total_income: totalInc, total_expense: totalExp, balance: totalInc - totalExp, max_expense_category: maxExp.category, max_expense_amount: maxExp.amount, recent_transactions: combined.slice(0, 5) }; } function getIncomeLogic() { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName("INCOME"); if (!sheet) return []; var rows = sheet.getDataRange().getValues(); var res = []; for (var i = 1; i < rows.length; i++) { res.push({ income_id: rows[i][0], date: String(rows[i][1]), month: String(rows[i][2]), category: rows[i][3], description: rows[i][4], amount: Number(rows[i][5]) }); } return res.reverse(); } function createIncomeLogic(data) { var amt = Number(data.amount); if (!data.date || !data.category || isNaN(amt) || amt <= 0) { throw new Error("Data pemasukan tidak valid. Jumlah harus > 0."); } var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName("INCOME"); var month = data.date.slice(0, 7); var income_id = "INC-" + data.date.replace(/-/g, '') + "-" + Math.floor(1000 + Math.random() * 9000); var now = new Date().toISOString(); sheet.appendRow([income_id, data.date, month, data.category, data.description || "", amt, now, now]); } function updateIncomeLogic(data) { var amt = Number(data.amount); if (!data.income_id || !data.date || !data.category || isNaN(amt) || amt <= 0) { throw new Error("Data edit pemasukan tidak valid."); } var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName("INCOME"); var rows = sheet.getDataRange().getValues(); var month = data.date.slice(0, 7); for (var i = 1; i < rows.length; i++) { if (rows[i][0] === data.income_id) { var rowNum = i + 1; sheet.getRange(rowNum, 2).setValue(data.date); sheet.getRange(rowNum, 3).setValue(month); sheet.getRange(rowNum, 4).setValue(data.category); sheet.getRange(rowNum, 5).setValue(data.description || ""); sheet.getRange(rowNum, 6).setValue(amt); sheet.getRange(rowNum, 8).setValue(new Date().toISOString()); return; } } throw new Error("ID pemasukan tidak ditemukan."); } function deleteIncomeLogic(income_id) { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName("INCOME"); var rows = sheet.getDataRange().getValues(); for (var i = 1; i < rows.length; i++) { if (rows[i][0] === income_id) { sheet.deleteRow(i + 1); return; } } throw new Error("ID pemasukan tidak ditemukan."); } function getExpensesLogic() { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName("EXPENSES"); if (!sheet) return []; var rows = sheet.getDataRange().getValues(); var res = []; for (var i = 1; i < rows.length; i++) { res.push({ expense_id: rows[i][0], date: String(rows[i][1]), month: String(rows[i][2]), category: rows[i][3], description: rows[i][4], amount: Number(rows[i][5]) }); } return res.reverse(); } function createExpenseLogic(data) { var amt = Number(data.amount); if (!data.date || !data.category || isNaN(amt) || amt <= 0) { throw new Error("Data pengeluaran tidak valid. Jumlah harus > 0."); } var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName("EXPENSES"); var month = data.date.slice(0, 7); var expense_id = "EXP-" + data.date.replace(/-/g, '') + "-" + Math.floor(1000 + Math.random() * 9000); var now = new Date().toISOString(); sheet.appendRow([expense_id, data.date, month, data.category, data.description || "", amt, now, now]); } function updateExpenseLogic(data) { var amt = Number(data.amount); if (!data.expense_id || !data.date || !data.category || isNaN(amt) || amt <= 0) { throw new Error("Data edit pengeluaran tidak valid."); } var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName("EXPENSES"); var rows = sheet.getDataRange().getValues(); var month = data.date.slice(0, 7); for (var i = 1; i < rows.length; i++) { if (rows[i][0] === data.expense_id) { var rowNum = i + 1; sheet.getRange(rowNum, 2).setValue(data.date); sheet.getRange(rowNum, 3).setValue(month); sheet.getRange(rowNum, 4).setValue(data.category); sheet.getRange(rowNum, 5).setValue(data.description || ""); sheet.getRange(rowNum, 6).setValue(amt); sheet.getRange(rowNum, 8).setValue(new Date().toISOString()); return; } } throw new Error("ID pengeluaran tidak ditemukan."); } function deleteExpenseLogic(expense_id) { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName("EXPENSES"); var rows = sheet.getDataRange().getValues(); for (var i = 1; i < rows.length; i++) { if (rows[i][0] === expense_id) { sheet.deleteRow(i + 1); return; } } throw new Error("ID pengeluaran tidak ditemukan."); } function getMonthlyReportLogic(month) { var dash = getDashboardDataLogic(month); var ss = SpreadsheetApp.getActiveSpreadsheet(); var expSheet = ss.getSheetByName("EXPENSES"); var expData = expSheet ? expSheet.getDataRange().getValues() : []; var expCategories = [ "Sayur & Lauk", "Listrik", "Air", "Internet", "Transportasi", "Pendidikan Anak 1", "Pendidikan Anak 2", "Pendidikan Anak 3", "Pendidikan Anak 4", "Pendidikan Anak 5", "Pendidikan Anak 6", "Pendidikan Anak 7", "Kesehatan", "Belanja", "Cicilan", "Hiburan", "Nyumbang", "Investasi", "Arisan", "Lainnya" ]; var expCatMap = {}; expCategories.forEach(function(c) { expCatMap[c] = 0; }); for (var i = 1; i < expData.length; i++) { var dStr = String(expData[i][1]); var cat = expData[i][3]; var amt = Number(expData[i][5]); if (!month || dStr.startsWith(month)) { expCatMap[cat] = (expCatMap[cat] || 0) + amt; } } var incSheet = ss.getSheetByName("INCOME"); var incData = incSheet ? incSheet.getDataRange().getValues() : []; var incCategories = ["Gaji", "Usaha", "Bonus", "Pendapatan Lainnya", "Lainnya"]; var incCatMap = {}; incCategories.forEach(function(c) { incCatMap[c] = 0; }); for (var j = 1; j < incData.length; j++) { var dateStr = String(incData[j][1]); var src = incData[j][3]; var amount = Number(incData[j][5]); if (!month || dateStr.startsWith(month)) { incCatMap[src] = (incCatMap[src] || 0) + amount; } } dash.expenses_by_category = expCatMap; dash.income_by_category = incCatMap; return dash; } function getSettingsLogic() { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName("SETTINGS"); if (!sheet) return { family_name: "Bendahara Keluarga", currency: "IDR", timezone: TIMEZONE }; var rows = sheet.getDataRange().getValues(); var res = {}; for (var i = 1; i < rows.length; i++) { res[rows[i][0]] = rows[i][1]; } return res; } function updateSettingsLogic(data) { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName("SETTINGS"); if (!sheet) return; var rows = sheet.getDataRange().getValues(); if (data.family_name) { for (var i = 1; i < rows.length; i++) { if (rows[i][0] === "family_name") { sheet.getRange(i + 1, 2).setValue(data.family_name); return; } } } }