===== Admin.html — copy this into the Apps Script Admin.html file ===== The Odd Company
F

FoodQR Admin

Sign in to manage your cafe

Your admin session is protected and expires after 6 hours.
===== Code.gs — copy this into the Apps Script Code.gs file ===== // ========================================================= // FOODQR CODE MAP // 01 WEB ENTRY + AUTO SHEET SETUP // 02 ADMIN DATA + MONTHLY DASHBOARD // 03 MENU CRUD + MENU READ // 04 CAFE / LEGAL SETTINGS + GSTIN FORMAT VALIDATION // 05 AUTOMATIC MASTER-SPREADSHEET STORAGE + SHEET HELPERS // 06 SHARED VALIDATION / DATE / MONEY HELPERS // 07 POS TABLES + ORDERS + KOT + BILL + PAYMENT + MERGE // 08 EXPENSES + STAFF + ATTENDANCE + PAYROLL // 09 TRENDS + CUSTOMER OFFERS + ELIGIBILITY / CLAIMS // 10 ANALYTICS + GST SUMMARY REPORTS // REQUIRED AUTO-CREATED TABS: Menu, Orders, Settings, POSTables, POSOrders, // POSPayments, Expenses, Staff, StaffAttendance, StaffPayroll, POSOffers, POSOfferGrants. // Use Ctrl+F for the numbered section comments to maintain a feature. // Change a feature in its matching numbered section below. // ========================================================= const CAFE_APP = { SETTINGS_SHEET: 'Settings', DEFAULT_MENU_SHEET: 'Menu', DEFAULT_ORDERS_SHEET: 'Orders', MENU_HEADERS: [ 'id', 'category', 'name', 'price', 'availability', 'foodtype', 'gstApplicable' ], ORDER_HEADERS: [ 'orderId', 'timestamp', 'customerName', 'customerPhone', 'orderType', 'deliveryAddress', 'tableNumber', 'orderNotes', 'itemsJson', 'itemsSummary', 'subtotal', 'gstPercent', 'gstAmount', 'total', 'status' ] }; // ========================================================= // FOODQR ADMIN USERNAME + PASSWORD AUTHENTICATION // - Passwords are stored as salted SHA-256 hashes in Script Properties. // - Sessions are random tokens held server-side in Script Cache (6 hours). // - All data-changing/reading admin RPCs pass through adminRpc(). // ========================================================= const FOODQR_AUTH = { SESSION_PREFIX: 'FOODQR_ADMIN_SESSION_', SESSION_TTL_SECONDS: 21600 // 6 hours }; /** * Run this ONCE from the Apps Script editor while signed in as the sheet owner. * It creates username "admin" and a random strong password, then logs the * password in the Apps Script execution log. The password itself is NOT stored. */ function setupFoodQRAdminLogin() { assertOwnerRunningSetup_(); const props = PropertiesService.getScriptProperties(); if (props.getProperty('FOODQR_ADMIN_PASSWORD_HASH')) { Logger.log('FoodQR admin login is already configured. Run resetFoodQRAdminLogin() only if you need to rotate the credentials.'); return; } createFoodQRAdminCredentials_(); } /** Owner-only credential reset. It also invalidates all active sessions. */ function resetFoodQRAdminLogin() { assertOwnerRunningSetup_(); createFoodQRAdminCredentials_(); } /** Public login endpoint; it returns a short-lived opaque session token. */ function adminLogin(username, password) { const props = PropertiesService.getScriptProperties(); const expectedUsername = String(props.getProperty('FOODQR_ADMIN_USERNAME') || ''); const salt = String(props.getProperty('FOODQR_ADMIN_SALT') || ''); const expectedHash = String(props.getProperty('FOODQR_ADMIN_PASSWORD_HASH') || ''); if (!expectedUsername || !salt || !expectedHash) { throw new Error('Admin login is not set up yet. Run setupFoodQRAdminLogin() once from the Apps Script editor.'); } const suppliedUsername = String(username || '').trim(); const suppliedPassword = String(password || ''); const actualHash = hashFoodQRPassword_(salt, suppliedPassword); if ( !constantTimeFoodQREqual_(suppliedUsername, expectedUsername) || !constantTimeFoodQREqual_(actualHash, expectedHash) ) { throw new Error('Incorrect username or password.'); } const token = Utilities.getUuid().replace(/-/g, '') + Utilities.getUuid().replace(/-/g, ''); const version = String(props.getProperty('FOODQR_ADMIN_AUTH_VERSION') || '1'); const cacheKey = foodQRSessionCacheKey_(token); CacheService.getScriptCache().put( cacheKey, JSON.stringify({ version: version, username: expectedUsername }), FOODQR_AUTH.SESSION_TTL_SECONDS ); return { token: token, expiresInSeconds: FOODQR_AUTH.SESSION_TTL_SECONDS }; } /** Logout only removes the supplied session token. */ function adminLogout(token) { const value = String(token || ''); if (value) CacheService.getScriptCache().remove(foodQRSessionCacheKey_(value)); return true; } /** * The only general-purpose admin data endpoint. * The allowlist prevents arbitrary function dispatch from the browser. */ function adminRpc(action, args, token) { assertFoodQRAdminSession_(token); assertOwner_(); const routes = { getAdminData: getAdminData_, saveMenuItem: saveMenuItem_, deleteMenuItem: deleteMenuItem_, saveCafeSettings: saveCafeSettings_, getPosOverviewData: getPosOverviewData_, createPosTable: createPosTable_, renamePosTable: renamePosTable_, deletePosTable: deletePosTable_, mergePosTables: mergePosTables_, savePosOrder: savePosOrder_, markPosBillPrinted: markPosBillPrinted_, cancelPosOrder: cancelPosOrder_, settlePosOrder: settlePosOrder_, createOffer: createOffer_, setOfferActive: setOfferActive_, getOffersDashboardData: getOffersDashboardData_, markOfferGiven: markOfferGiven_, getItemSalesTrends: getItemSalesTrends_, saveExpense: saveExpense_, saveStaff: saveStaff_, saveStaffAttendance: saveStaffAttendance_, saveStaffPayroll: saveStaffPayroll_ }; const handler = routes[String(action || '')]; if (typeof handler !== 'function') { throw new Error('This admin action is not allowed.'); } return handler.apply(null, Array.isArray(args) ? args : []); } function assertFoodQRAdminSession_(token) { const value = String(token || ''); if (!value || value.length < 40) { throw new Error('Your login session is missing. Please sign in again.'); } const cached = CacheService.getScriptCache().get(foodQRSessionCacheKey_(value)); if (!cached) { throw new Error('Your login session has expired. Please sign in again.'); } let session; try { session = JSON.parse(cached); } catch (error) { throw new Error('Your login session is invalid. Please sign in again.'); } const version = String( PropertiesService.getScriptProperties().getProperty('FOODQR_ADMIN_AUTH_VERSION') || '1' ); if (!session || String(session.version) !== version) { throw new Error('Your login session has expired. Please sign in again.'); } } function createFoodQRAdminCredentials_() { const props = PropertiesService.getScriptProperties(); const username = 'admin'; const password = 'FQ-' + Utilities.getUuid().replace(/-/g, '') + '-' + Utilities.getUuid().replace(/-/g, '').slice(0, 8); const salt = Utilities.getUuid() + Utilities.getUuid(); const oldVersion = Number(props.getProperty('FOODQR_ADMIN_AUTH_VERSION') || 0); const nextVersion = String(oldVersion + 1); props.setProperties({ FOODQR_ADMIN_USERNAME: username, FOODQR_ADMIN_SALT: salt, FOODQR_ADMIN_PASSWORD_HASH: hashFoodQRPassword_(salt, password), FOODQR_ADMIN_AUTH_VERSION: nextVersion }, false); Logger.log('FOODQR ADMIN LOGIN — copy these credentials now:'); Logger.log('Username: ' + username); Logger.log('Password: ' + password); Logger.log('The password is shown only in this execution log; it is not saved as plain text.'); } function assertOwnerRunningSetup_() { const props = PropertiesService.getScriptProperties(); const expected = String(props.getProperty('OWNER_EMAIL') || '').toLowerCase(); const active = String(Session.getActiveUser().getEmail() || '').toLowerCase(); const effective = String(Session.getEffectiveUser().getEmail() || '').toLowerCase(); // In the editor, active and effective users are the owner. For public web-app // visitors running as the owner, active user email is normally unavailable. if (!active || !effective || active !== effective || (expected && active !== expected)) { throw new Error('For security, run this setup/reset function directly from the Apps Script editor while signed in as the spreadsheet owner.'); } if (!expected) props.setProperty('OWNER_EMAIL', active); } function hashFoodQRPassword_(salt, password) { const bytes = Utilities.computeDigest( Utilities.DigestAlgorithm.SHA_256, String(salt) + '|' + String(password), Utilities.Charset.UTF_8 ); return bytes.map(function (byte) { const unsigned = (byte + 256) % 256; return ('0' + unsigned.toString(16)).slice(-2); }).join(''); } function constantTimeFoodQREqual_(left, right) { const a = String(left); const b = String(right); let mismatch = a.length ^ b.length; const length = Math.max(a.length, b.length); for (let i = 0; i < length; i++) { mismatch |= (a.charCodeAt(i % (a.length || 1)) || 0) ^ (b.charCodeAt(i % (b.length || 1)) || 0); } return mismatch === 0; } function foodQRSessionCacheKey_(token) { const digest = Utilities.computeDigest( Utilities.DigestAlgorithm.SHA_256, String(token), Utilities.Charset.UTF_8 ); const tokenHash = digest.map(function (byte) { const unsigned = (byte + 256) % 256; return ('0' + unsigned.toString(16)).slice(-2); }).join(''); return FOODQR_AUTH.SESSION_PREFIX + tokenHash; } // ========================================================= // 01. WEB APP ENTRY + FIRST-RUN SHEET INITIALIZATION // Bind this Apps Script to the cafe Google Sheet (Extensions > Apps Script). // The first admin load creates only missing tabs/headers; existing data is kept. // ========================================================= function doGet() { try { initializeCafeSystem_(); return HtmlService.createHtmlOutputFromFile('Admin').setTitle('FoodQR Admin'); } catch (error) { const message = String(error && error.message ? error.message : error); return HtmlService.createHtmlOutput( '
' + '

FoodQR setup required

' + message.replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch])) + '

Open the bound Google Sheet, choose Extensions → Apps Script, save the project, then reload this web app. FoodQR will create missing tabs automatically; approve the Google authorization prompt if shown.

' ).setTitle('FoodQR setup required'); } } /** * Ensure every required FoodQR tab and header in the bound master spreadsheet. * This is the only tab registry to maintain when adding a new module. * ensureSheetWithHeaders_ appends missing headers and never clears populated rows. */ function ensureAllAppSheets_() { const ss = getConfigSpreadsheet_(); const core = { menu: ensureSheetWithHeaders_(ss, CAFE_APP.DEFAULT_MENU_SHEET, CAFE_APP.MENU_HEADERS), orders: ensureSheetWithHeaders_(ss, CAFE_APP.DEFAULT_ORDERS_SHEET, CAFE_APP.ORDER_HEADERS), settings: ensureSheetWithHeaders_(ss, CAFE_APP.SETTINGS_SHEET, ['key', 'value']) }; const pos = ensurePosSheets_(); const finance = ensureFinanceSheets_(); const offers = ensureOfferSheets_(); return { core: core, pos: pos, finance: finance, offers: offers, tabNames: [ CAFE_APP.DEFAULT_MENU_SHEET, CAFE_APP.DEFAULT_ORDERS_SHEET, CAFE_APP.SETTINGS_SHEET, POS_APP.TABLES_SHEET, POS_APP.ORDERS_SHEET, POS_APP.PAYMENTS_SHEET, FINANCE_APP.EXPENSES_SHEET, FINANCE_APP.STAFF_SHEET, FINANCE_APP.ATTENDANCE_SHEET, FINANCE_APP.PAYROLL_SHEET, OFFER_APP.OFFERS_SHEET, OFFER_APP.GRANTS_SHEET ] }; } /** Create/repair the app's tabs and headers without clearing existing rows. */ function initializeCafeSystem_() { const props = PropertiesService.getScriptProperties(); const configuredId = props.getProperty('CONFIG_SPREADSHEET_ID'); let ss = configuredId ? SpreadsheetApp.openById(configuredId) : SpreadsheetApp.getActiveSpreadsheet(); if (!ss) { throw new Error('No backing spreadsheet is connected. Create/open a Google Sheet and attach Apps Script from Extensions → Apps Script.'); } if (!configuredId) props.setProperty('CONFIG_SPREADSHEET_ID', ss.getId()); const email = Session.getEffectiveUser().getEmail() || ''; if (email && !props.getProperty('OWNER_EMAIL')) props.setProperty('OWNER_EMAIL', email.toLowerCase()); const appSheets = ensureAllAppSheets_(); const settingsSheet = appSheets.core.settings; const defaults = { ownerEmail: email, cafeName: 'Your Cafe', cafeLogoUrl: '', cafeAddress: '', cafePhone: '', legalBusinessName: '', legalAddress: '', gstin: '', gstRegistrationType: 'UNREGISTERED', gstPercent: '0', fssaiLicenseNo: '', menuSpreadsheetUrl: ss.getUrl(), menuCsvUrl: '', menuSheetName: CAFE_APP.DEFAULT_MENU_SHEET, ordersSpreadsheetUrl: ss.getUrl(), ordersSheetName: CAFE_APP.DEFAULT_ORDERS_SHEET }; const existing = readSettingsFromSheet_(settingsSheet); Object.keys(defaults).forEach(key => { if (existing[key] === undefined || existing[key] === '') writeSetting_(settingsSheet, key, defaults[key]); }); // One-time legacy migration; never scan the complete POS sheet on every refresh. if (props.getProperty('POS_ORDER_NUMBERS_BACKFILLED') !== '1') { ensurePosOrderNumbers_(appSheets.pos.orders); props.setProperty('POS_ORDER_NUMBERS_BACKFILLED', '1'); } SpreadsheetApp.flush(); return ss; } // Run manually once if this is a newly created/bound Apps Script project. function setupCafeApp_() { const ss = initializeCafeSystem_(); try { SpreadsheetApp.getUi().alert('FoodQR setup complete. All 12 required tabs and their headers are ready: Menu, Orders, Settings, POSTables, POSOrders, POSPayments, Expenses, Staff, StaffAttendance, StaffPayroll, POSOffers and POSOfferGrants.'); } catch (error) { Logger.log('FoodQR setup complete for spreadsheet: ' + ss.getUrl()); } } // ========================================================= // LOAD ALL ADMIN DATA // ========================================================= function getAdminData_() { const props = PropertiesService.getScriptProperties(); if (!props.getProperty('CONFIG_SPREADSHEET_ID')) initializeCafeSystem_(); assertOwner_(); const settings = getSettings_(); const menuItems = readMenuItems_(settings); const qrOrders = readOrders_(settings); const pos = readPosData_(settings); const finance = readFinanceData_(); // Dashboard figures reset at the start of each calendar month. const now = new Date(); const monthStart = Utilities.formatDate( new Date(now.getFullYear(), now.getMonth(), 1), Session.getScriptTimeZone(), 'yyyy-MM-dd' ); const todayKey = Utilities.formatDate(now, Session.getScriptTimeZone(), 'yyyy-MM-dd'); const qrOrdersThisMonth = qrOrders.filter(order => { const key = financeDateKey_(order.timestamp); return key && key >= monthStart && key <= todayKey; }); const posOrdersThisMonth = (pos.orders || []).filter(order => { const key = financeDateKey_(order.createdAt); return key && key >= monthStart && key <= todayKey && order.status !== 'CANCELLED'; }); const settledOrdersThisMonth = posOrdersThisMonth.filter(order => { if (String(order.status).toUpperCase() !== 'SETTLED') return false; const key = financeDateKey_(order.settledAt || order.createdAt); return key && key >= monthStart && key <= todayKey; }); const monthPosCustomers = posOrdersThisMonth.map(order => ({ customerName: order.customerName || '', customerPhone: order.customerPhone || '', timestamp: order.createdAt || '', timestampMs: posDateMs_(order.createdAt) })); const monthCustomers = buildCustomers_(qrOrdersThisMonth.concat(monthPosCustomers)); const revenue = settledOrdersThisMonth.reduce( (sum, order) => sum + (Number(order.total) || 0), 0 ); return { settings: settings, menuItems: menuItems, orders: qrOrders.slice().reverse().slice(0, 100), customers: buildCustomers_(qrOrders.concat((pos.orders || []).map(order => ({ customerName: order.customerName || '', customerPhone: order.customerPhone || '', timestamp: order.createdAt || '', timestampMs: posDateMs_(order.createdAt) })))), pos: pos, finance: finance, stats: { totalUsers: monthCustomers.length, totalOrders: qrOrdersThisMonth.length + posOrdersThisMonth.length, settledOrders: settledOrdersThisMonth.length, confirmedOrders: qrOrdersThisMonth.length, menuItems: menuItems.length, estimatedRevenue: roundMoney_(revenue), periodLabel: Utilities.formatDate(now, Session.getScriptTimeZone(), 'MMMM yyyy') } }; } function saveMenuItem_(payload) { assertOwner_(); if (!payload || typeof payload !== 'object') { throw new Error('Invalid menu item.'); } const name = cleanText_(payload.name, 100); const category = cleanText_(payload.category, 60); const price = Number(payload.price); const foodtypeInput = String(payload.foodtype || '').trim().toLowerCase().replace(/[ _]/g, '-'); const foodtype = foodtypeInput === 'veg' ? 'veg' : (['nonveg', 'non-veg', 'non-vegetarian'].includes(foodtypeInput) ? 'nonveg' : ''); if (!foodtype) { throw new Error('Please select Veg or Non-Veg.'); } if (!name) throw new Error('Item name is required.'); if (!category) throw new Error('Category is required.'); if (!Number.isFinite(price) || price < 0 || price > 1000000) { throw new Error('Enter a valid price.'); } // Dropdown value ko Yes/No mein normalize karo. const availabilityInput = String( payload.availability == null ? 'Yes' : payload.availability ).trim().toLowerCase(); if (!['yes', 'no'].includes(availabilityInput)) { throw new Error('Availability must be Available or Out of Stock.'); } const availability = availabilityInput === 'no' ? 'No' : 'Yes'; const gstApplicableInput = String(payload.gstApplicable == null ? 'Yes' : payload.gstApplicable).trim().toLowerCase(); if (!['yes', 'no'].includes(gstApplicableInput)) throw new Error('GST Applicable must be Yes or No.'); const gstApplicable = gstApplicableInput === 'no' ? 'No' : 'Yes'; const settings = getSettings_(); const sheet = getConfiguredSheet_( settings.menuSpreadsheetUrl, settings.menuSheetName, CAFE_APP.MENU_HEADERS, true ); const headers = getHeaderMap_(sheet); requireHeaders_(headers, CAFE_APP.MENU_HEADERS); const idColumn = headers.id; const lastRow = sheet.getLastRow(); let rowNumber = 0; const wantedId = cleanText_(payload.id, 100); if (wantedId && lastRow >= 2) { const ids = sheet .getRange(2, idColumn + 1, lastRow - 1, 1) .getDisplayValues() .flat(); const match = ids.findIndex( value => String(value).trim() === wantedId ); if (match >= 0) { rowNumber = match + 2; } } const width = sheet.getLastColumn(); const row = rowNumber ? sheet.getRange(rowNumber, 1, 1, width).getValues()[0] : Array(width).fill(''); const id = wantedId || Utilities.getUuid(); row[headers.id] = id; row[headers.category] = category; row[headers.name] = name; row[headers.price] = price; row[headers.availability] = availability; if (headers.foodtype !== undefined) { row[headers.foodtype] = foodtype; } if (headers.gstapplicable !== undefined) { row[headers.gstapplicable] = gstApplicable; } if (rowNumber) { sheet.getRange(rowNumber, 1, 1, width).setValues([row]); } else { sheet.appendRow(row); } SpreadsheetApp.flush(); return { ok: true, item: { id: id, category: category, name: name, price: price, availability: availability, foodtype: foodtype, gstApplicable: gstApplicable } }; } // ========================================================= // DELETE MENU ITEM // ========================================================= function deleteMenuItem_(itemId) { assertOwner_(); const id = cleanText_(itemId, 100); if (!id) { throw new Error('Missing item ID.'); } const settings = getSettings_(); const sheet = getConfiguredSheet_( settings.menuSpreadsheetUrl, settings.menuSheetName, CAFE_APP.MENU_HEADERS, true ); const headers = getHeaderMap_(sheet); requireHeaders_( headers, CAFE_APP.MENU_HEADERS ); const lastRow = sheet.getLastRow(); if (lastRow < 2) { throw new Error('Menu is empty.'); } const ids = sheet .getRange( 2, headers.id + 1, lastRow - 1, 1 ) .getDisplayValues() .flat(); const index = ids.findIndex( value => String(value).trim() === id ); if (index < 0) { throw new Error( 'This item was not found. Refresh the menu and try again.' ); } sheet.deleteRow(index + 2); return { ok: true }; } // ========================================================= // SAVE CAFE SETTINGS // ========================================================= function saveCafeSettings_(payload) { assertOwner_(); payload = payload || {}; const cafeName = cleanText_(payload.cafeName, 100); const cafeLogoUrl = cleanUrl_(payload.cafeLogoUrl, true); const cafeAddress = cleanText_(payload.cafeAddress, 250); const cafePhone = cleanText_(payload.cafePhone, 40).replace(/[^0-9+() -]/g, ''); const legalBusinessName = cleanText_(payload.legalBusinessName, 150); const legalAddress = cleanText_(payload.legalAddress, 300); const gstin = String(payload.gstin || '').trim().toUpperCase(); const gstRegistrationType = String(payload.gstRegistrationType || 'UNREGISTERED').trim().toUpperCase(); const fssaiLicenseNo = cleanText_(payload.fssaiLicenseNo, 30).toUpperCase(); let gstPercent = Number(payload.gstPercent); if (!cafeName) throw new Error('Cafe / display name is required.'); if (!['UNREGISTERED', 'REGULAR', 'COMPOSITION'].includes(gstRegistrationType)) { throw new Error('Select a valid GST registration type.'); } if (gstRegistrationType === 'REGULAR') { if (!legalBusinessName) throw new Error('Legal business name is required for a regular GST-registered cafe.'); if (!legalAddress) throw new Error('Legal business address is required for a regular GST-registered cafe.'); if (!isValidGstinFormat_(gstin)) { throw new Error('Enter a GSTIN in the valid 15-character format. Check the state code, PAN portion and Z position.'); } if (!Number.isFinite(gstPercent) || gstPercent < 0 || gstPercent > 28) { throw new Error('GST rate must be between 0 and 28. Confirm the applicable rate with the cafe CA.'); } } else { // Unregistered/composition businesses must not separately collect GST in this POS. gstPercent = 0; } if (fssaiLicenseNo && !/^[A-Z0-9\/-]{8,30}$/.test(fssaiLicenseNo)) { throw new Error('Enter a valid FSSAI licence/registration reference, or leave it blank.'); } const settings = { cafeName: cafeName, cafeLogoUrl: cafeLogoUrl, cafeAddress: cafeAddress, cafePhone: cafePhone, legalBusinessName: legalBusinessName, legalAddress: legalAddress, gstin: gstRegistrationType === 'REGULAR' ? gstin : '', gstRegistrationType: gstRegistrationType, gstPercent: String(gstPercent), fssaiLicenseNo: fssaiLicenseNo }; saveSettings_(settings); return { ok: true, settings: getSettings_() }; } /** Format/structure check only; verify GSTIN status on the GST portal before using it. */ function isValidGstinFormat_(gstin) { const value = String(gstin || '').trim().toUpperCase(); if (!/^(0[1-9]|[12][0-9]|3[0-8]|97)[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/.test(value)) return false; const panPart = value.substring(2, 12); return /^[A-Z]{5}[0-9]{4}[A-Z]$/.test(panPart); } // ========================================================= // LEGACY CONNECTION WRITER (HIDDEN FROM ADMIN UI) // Kept only for backwards compatibility with older deployments. // New installations automatically use Menu and Orders tabs in the master workbook. // ========================================================= function saveSheetSettings_(payload) { assertOwner_(); payload = payload || {}; const menuSpreadsheetUrl = cleanUrl_( payload.menuSpreadsheetUrl, false ); const menuCsvUrl = cleanUrl_( payload.menuCsvUrl, true ); const menuSheetName = cleanText_( payload.menuSheetName || CAFE_APP.DEFAULT_MENU_SHEET, 80 ); const ordersSpreadsheetUrl = cleanUrl_( payload.ordersSpreadsheetUrl, false ); const ordersSheetName = cleanText_( payload.ordersSheetName || CAFE_APP.DEFAULT_ORDERS_SHEET, 80 ); const configId = PropertiesService .getScriptProperties() .getProperty('CONFIG_SPREADSHEET_ID'); if ( !menuSpreadsheetUrl || !extractSpreadsheetId_(menuSpreadsheetUrl) ) { throw new Error( 'Enter a valid Menu Google Sheets URL.' ); } if ( !ordersSpreadsheetUrl || !extractSpreadsheetId_(ordersSpreadsheetUrl) ) { throw new Error( 'Enter a valid Orders/Customer Data Google Sheets URL.' ); } if ( menuCsvUrl && !/^https:\/\/docs\.google\.com\/spreadsheets\//i.test(menuCsvUrl) ) { throw new Error( 'Published menu CSV URL must be a Google Sheets URL.' ); } if (!menuSheetName || !ordersSheetName) { throw new Error('Sheet tab names are required.'); } // Verify access and ensure required header columns exist. getConfiguredSheet_( menuSpreadsheetUrl, menuSheetName, CAFE_APP.MENU_HEADERS, true ); getConfiguredSheet_( ordersSpreadsheetUrl, ordersSheetName, CAFE_APP.ORDER_HEADERS, true ); saveSettings_({ menuSpreadsheetUrl: menuSpreadsheetUrl, menuCsvUrl: menuCsvUrl, menuSheetName: menuSheetName, ordersSpreadsheetUrl: ordersSpreadsheetUrl, ordersSheetName: ordersSheetName }); return { ok: true, settings: getSettings_(), configSpreadsheetId: configId }; } // ========================================================= // REFRESH // ========================================================= function refreshAdminData_() { return getAdminData_(); } // Lightweight endpoint used after KOT, bill print, table changes and settlement. // It avoids re-reading QR order history, menu, expenses, payroll and analytics. function getPosOverviewData_() { assertOwner_(); return readPosData_(); } // ========================================================= // OWNER ACCESS // ========================================================= function assertOwner_() { const props = PropertiesService.getScriptProperties(); const expected = String( props.getProperty('OWNER_EMAIL') || '' ).toLowerCase(); const effective = String( Session.getEffectiveUser().getEmail() || '' ).toLowerCase(); if (!props.getProperty('CONFIG_SPREADSHEET_ID')) { throw new Error( 'Run setupCafeApp_() once from the Apps Script editor first.' ); } if ( expected && effective && expected !== effective ) { throw new Error( 'Access denied: this dashboard belongs to a different Google account.' ); } // Keep deployment restricted to: // Execute as Me / Who has access: Only myself. } // ========================================================= // SPREADSHEET SETTINGS // ========================================================= function getConfigSpreadsheet_() { const id = PropertiesService .getScriptProperties() .getProperty('CONFIG_SPREADSHEET_ID'); if (!id) { throw new Error( 'Config spreadsheet is not set up. Run setupCafeApp_() from the editor.' ); } return SpreadsheetApp.openById(id); } function getSettings_() { const ss = getConfigSpreadsheet_(); const sheet = ss.getSheetByName(CAFE_APP.SETTINGS_SHEET) || ensureSheetWithHeaders_(ss, CAFE_APP.SETTINGS_SHEET, ['key', 'value']); const stored = readSettingsFromSheet_(sheet); const defaultUrl = ss.getUrl(); const settings = Object.assign({ ownerEmail: PropertiesService.getScriptProperties().getProperty('OWNER_EMAIL') || '', cafeName: 'Your Cafe', cafeLogoUrl: '', cafeAddress: '', cafePhone: '', legalBusinessName: '', legalAddress: '', gstin: '', gstRegistrationType: 'UNREGISTERED', gstPercent: '0', fssaiLicenseNo: '', menuSpreadsheetUrl: defaultUrl, menuCsvUrl: '', menuSheetName: CAFE_APP.DEFAULT_MENU_SHEET, ordersSpreadsheetUrl: defaultUrl, ordersSheetName: CAFE_APP.DEFAULT_ORDERS_SHEET }, stored); settings.gstRegistrationType = String(settings.gstRegistrationType || 'UNREGISTERED').toUpperCase(); if (settings.gstRegistrationType !== 'REGULAR') settings.gstPercent = '0'; return settings; } function saveSettings_(updates) { const ss = getConfigSpreadsheet_(); const sheet = ss.getSheetByName(CAFE_APP.SETTINGS_SHEET) || ensureSheetWithHeaders_( ss, CAFE_APP.SETTINGS_SHEET, ['key', 'value'] ); Object.keys(updates).forEach(key => { writeSetting_(sheet, key, updates[key]); }); } function readSettingsFromSheet_(sheet) { const values = sheet.getDataRange().getDisplayValues(); const output = {}; if (!values.length) { return output; } const headers = values[0].map( value => String(value).trim().toLowerCase() ); const keyColumn = headers.indexOf('key'); const valueColumn = headers.indexOf('value'); if (keyColumn < 0 || valueColumn < 0) { return output; } for (let i = 1; i < values.length; i++) { const key = String( values[i][keyColumn] || '' ).trim(); if (key) { output[key] = String( values[i][valueColumn] || '' ).trim(); } } return output; } function writeSetting_(sheet, key, value) { const values = sheet.getDataRange().getDisplayValues(); if (!values.length) { sheet.getRange(1, 1, 1, 2).setValues([ ['key', 'value'] ]); } const refreshedValues = sheet.getDataRange().getDisplayValues(); const headers = refreshedValues[0].map( value => String(value).trim().toLowerCase() ); let keyColumn = headers.indexOf('key'); let valueColumn = headers.indexOf('value'); if (keyColumn < 0 || valueColumn < 0) { throw new Error( 'Settings sheet must have key and value headers.' ); } for (let i = 1; i < refreshedValues.length; i++) { if ( String(refreshedValues[i][keyColumn]).trim() === key ) { sheet .getRange(i + 1, valueColumn + 1) .setValue(value == null ? '' : value); return; } } sheet.appendRow([ key, value == null ? '' : value ]); } // ========================================================= // READ MENU ITEMS // ========================================================= function readMenuItems_(settings) { const sheet = getConfiguredSheet_( settings.menuSpreadsheetUrl, settings.menuSheetName, CAFE_APP.MENU_HEADERS, true ); const data = sheet.getDataRange().getValues(); if (data.length < 2) { return []; } const headers = headerMapFromRow_(data[0]); requireHeaders_( headers, CAFE_APP.MENU_HEADERS ); return data .slice(1) .map((row, index) => { const availability = headers.availability === undefined ? 'Yes' : ( String(row[headers.availability] || 'Yes') .trim() .toLowerCase() === 'no' ? 'No' : 'Yes' ); return { id: String( row[headers.id] == null ? '' : row[headers.id] ).trim(), category: String( row[headers.category] == null ? '' : row[headers.category] ).trim(), name: String( row[headers.name] == null ? '' : row[headers.name] ).trim(), price: Number(row[headers.price]), availability: availability, foodtype: String( row[headers.foodtype] == null ? '' : row[headers.foodtype] ).trim(), gstApplicable: String( headers.gstapplicable === undefined || row[headers.gstapplicable] == null || row[headers.gstapplicable] === '' ? 'Yes' : row[headers.gstapplicable] ).trim().toLowerCase() === 'no' ? 'No' : 'Yes', rowNumber: index + 2 }; }) .filter(item => item.id && item.name && Number.isFinite(item.price) && item.price >= 0 ); } // ========================================================= // READ ORDERS // ========================================================= function readOrders_(settings) { const sheet = getConfiguredSheet_( settings.ordersSpreadsheetUrl, settings.ordersSheetName, CAFE_APP.ORDER_HEADERS, true ); const data = sheet.getDataRange().getValues(); if (data.length < 2) { return []; } // Header names are mapped to lowercase keys. const headers = headerMapFromRow_(data[0]); const required = [ 'orderId', 'timestamp', 'customerName', 'customerPhone', 'orderType', 'itemsSummary', 'subtotal', 'gstAmount', 'total', 'status' ]; requireHeaders_(headers, required); return data .slice(1) .filter(row => row.some(value => value !== '' && value != null) ) .map(row => { const stamp = row[headers.timestamp]; let timestampText = ''; let timestampMs = 0; if ( stamp instanceof Date && !isNaN(stamp.getTime()) ) { timestampMs = stamp.getTime(); timestampText = Utilities.formatDate( stamp, Session.getScriptTimeZone(), 'dd MMM yyyy, hh:mm a' ); } else { timestampText = String(stamp || ''); timestampMs = Date.parse(timestampText) || 0; } return { orderId: String( row[headers.orderid] || '' ), timestamp: timestampText, timestampMs: timestampMs, customerName: String( row[headers.customername] || '' ), customerPhone: String( row[headers.customerphone] || '' ).trim(), orderType: String( row[headers.ordertype] || '' ), deliveryAddress: String( row[headers.deliveryaddress] || '' ), tableNumber: String( row[headers.tablenumber] || '' ), orderNotes: String( row[headers.ordernotes] || '' ), itemsJson: String( row[headers.itemsjson] || '' ), itemsSummary: String( row[headers.itemssummary] || '' ), subtotal: Number( row[headers.subtotal] ) || 0, gstPercent: Number( row[headers.gstpercent] ) || 0, gstAmount: Number( row[headers.gstamount] ) || 0, total: Number( row[headers.total] ) || 0, status: String( row[headers.status] || 'Saved' ) }; }) .sort( (a, b) => a.timestampMs - b.timestampMs ); } // ========================================================= // BUILD UNIQUE CUSTOMER LIST // ========================================================= function normalizeIndianCustomerPhone_(value) { let digits = String(value == null ? '' : value).replace(/\D/g, ''); if (digits.length === 12 && digits.startsWith('91')) digits = digits.slice(2); return /^[6-9]\d{9}$/.test(digits) ? digits : ''; } function buildCustomers_(orders) { const map = new Map(); (orders || []).forEach(order => { const phone = normalizeIndianCustomerPhone_(order.customerPhone); // Customer Data is intended for usable, valid and unique Indian mobile records. if (!phone) return; const name = String(order.customerName || '').trim(); const timestamp = order.timestamp || order.createdAt || ''; const timestampMs = Number(order.timestampMs) || Date.parse(String(timestamp)) || 0; if (!map.has(phone)) { map.set(phone, { name: name, phone: phone, orders: 0, lastOrder: timestamp, _lastOrderMs: timestampMs }); } const entry = map.get(phone); entry.orders += 1; if (timestampMs >= entry._lastOrderMs) { if (name) entry.name = name; entry.lastOrder = timestamp || entry.lastOrder; entry._lastOrderMs = timestampMs; } else if (!entry.name && name) { entry.name = name; } }); return Array.from(map.values()) .sort((a, b) => b._lastOrderMs - a._lastOrderMs) .map(entry => { delete entry._lastOrderMs; return entry; }); } function getConfiguredSheet_( spreadsheetUrl, sheetName, expectedHeaders, createIfMissing ) { const id = extractSpreadsheetId_(spreadsheetUrl) || PropertiesService .getScriptProperties() .getProperty('CONFIG_SPREADSHEET_ID'); if (!id) { throw new Error( 'Enter a valid Google Sheets link in Sheets API settings.' ); } const ss = SpreadsheetApp.openById(id); const name = String(sheetName || '').trim(); if (!name) { throw new Error('Sheet tab name is empty.'); } let sheet = ss.getSheetByName(name); if (!sheet) { if (!createIfMissing) { throw new Error( 'Sheet tab "' + name + '" was not found.' ); } sheet = ss.insertSheet(name); } ensureExpectedHeaders_( sheet, expectedHeaders ); return sheet; } // ========================================================= // ENSURE EXPECTED HEADERS WITHOUT OVERWRITING DATA // ========================================================= function ensureSheetWithHeaders_( ss, name, headers ) { let sheet = ss.getSheetByName(name); if (!sheet) { sheet = ss.insertSheet(name); } ensureExpectedHeaders_( sheet, headers ); return sheet; } function ensureExpectedHeaders_(sheet, expectedHeaders) { const lastRow = sheet.getLastRow(); const lastColumn = sheet.getLastColumn(); // Completely blank sheet. if ( lastRow === 0 || ( lastRow === 1 && lastColumn === 1 && !sheet.getRange(1, 1).getValue() ) ) { sheet.clear(); sheet .getRange( 1, 1, 1, expectedHeaders.length ) .setValues([expectedHeaders]); sheet.setFrozenRows(1); sheet .getRange( 1, 1, 1, expectedHeaders.length ) .setFontWeight('bold'); return; } const existingHeaders = sheet .getRange(1, 1, 1, lastColumn) .getDisplayValues()[0]; const existingMap = headerMapFromRow_(existingHeaders); const missingHeaders = expectedHeaders.filter( header => existingMap[ String(header) .replace(/\s+/g, '') .trim() .toLowerCase() ] === undefined ); if (!missingHeaders.length) { return; } // Add only missing columns at the end. // Existing rows and values are left intact. const startColumn = lastColumn + 1; sheet .getRange( 1, startColumn, 1, missingHeaders.length ) .setValues([missingHeaders]); sheet .getRange( 1, startColumn, 1, missingHeaders.length ) .setFontWeight('bold'); } // ========================================================= // HEADER HELPERS // ========================================================= function getHeaderMap_(sheet) { return headerMapFromRow_( sheet .getRange( 1, 1, 1, sheet.getLastColumn() ) .getDisplayValues()[0] ); } function headerMapFromRow_(row) { const map = {}; row.forEach((value, index) => { const key = String(value || '') .replace(/\s+/g, '') .trim() .toLowerCase(); if (key) { map[key] = index; } }); return map; } function requireHeaders_(map, required) { const missing = required.filter( key => map[key.toLowerCase()] === undefined ); if (missing.length) { throw new Error( 'Missing required header(s): ' + missing.join(', ') + '. Check the first row of your sheet.' ); } } // ========================================================= // GENERAL HELPERS // ========================================================= function extractSpreadsheetId_(url) { const match = String(url || '').match( /\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/ ); if (match) { return match[1]; } const raw = String(url || '').trim(); return /^[a-zA-Z0-9_-]{25,}$/.test(raw) ? raw : ''; } function cleanText_(value, maxLength) { return String(value == null ? '' : value) .replace(/[\u0000-\u001F\u007F]/g, ' ') .trim() .slice(0, maxLength); } function cleanUrl_(value, allowBlank) { const url = String( value == null ? '' : value ).trim(); if (!url && allowBlank) { return ''; } if (!/^https:\/\//i.test(url)) { throw new Error('Use a full https:// URL.'); } return url.slice(0, 1000); } function roundMoney_(value) { return Math.round( (Number(value) + Number.EPSILON) * 100 ) / 100; } // ========================================================= // FOODQR POS MODULE // POS data is kept in separate tabs in the private config spreadsheet. // Existing Menu, Orders and Settings tabs are not modified. // ========================================================= const POS_APP = { TABLES_SHEET: 'POSTables', ORDERS_SHEET: 'POSOrders', PAYMENTS_SHEET: 'POSPayments', TABLE_HEADERS: ['tableId', 'tableName', 'createdAt', 'active'], ORDER_HEADERS: [ 'orderId', 'tableId', 'tableName', 'itemsJson', 'itemsSummary', 'subtotal', 'gstPercent', 'cgstAmount', 'sgstAmount', 'gstAmount', 'total', 'status', 'createdAt', 'updatedAt', 'billPrintedAt', 'settledAt', 'orderType', 'discountType', 'discountValue', 'discountAmount', 'orderNumber', 'customerName', 'customerPhone', 'deliveryAddress', 'orderNotes', 'deliveryCharge', 'roundOff', 'cancelledAt', 'cancelReason', 'mergedIntoOrderId', 'mergedAt' ], PAYMENT_HEADERS: [ 'paymentId', 'orderId', 'tableId', 'tableName', 'paymentMethod', 'amount', 'paidAt' ] }; function ensurePosSheets_() { const ss = getConfigSpreadsheet_(); const tables = ensureSheetWithHeaders_(ss, POS_APP.TABLES_SHEET, POS_APP.TABLE_HEADERS); const orders = ensureSheetWithHeaders_(ss, POS_APP.ORDERS_SHEET, POS_APP.ORDER_HEADERS); const payments = ensureSheetWithHeaders_(ss, POS_APP.PAYMENTS_SHEET, POS_APP.PAYMENT_HEADERS); return { tables: tables, orders: orders, payments: payments }; } function readPosData_(settings) { settings = settings || getSettings_(); const sheets = ensurePosSheets_(); const configuredGstPercent = getPosGstPercent_(settings); const tables = readPosSheetObjects_(sheets.tables, POS_APP.TABLE_HEADERS) .filter(row => String(row.active).toLowerCase() !== 'false') .map(row => ({ tableId: String(row.tableId || ''), tableName: String(row.tableName || ''), createdAt: posDateText_(row.createdAt) })); const payments = readPosSheetObjects_(sheets.payments, POS_APP.PAYMENT_HEADERS) .map(row => ({ paymentId: String(row.paymentId || ''), orderId: String(row.orderId || ''), paymentMethod: String(row.paymentMethod || ''), amount: roundMoney_(Number(row.amount) || 0), paidAt: posDateText_(row.paidAt) })); const paymentsByOrder = {}; payments.forEach(payment => { if (!paymentsByOrder[payment.orderId]) paymentsByOrder[payment.orderId] = []; paymentsByOrder[payment.orderId].push(payment); }); const orders = readPosSheetObjects_(sheets.orders, POS_APP.ORDER_HEADERS) .filter(row => row.orderId) .map(row => { const status = String(row.status || 'OPEN').toUpperCase(); const createdAt = posDateText_(row.createdAt); const order = { orderId: String(row.orderId || ''), orderNumber: Number(row.orderNumber) || 0, tableId: String(row.tableId || ''), tableName: String(row.tableName || ''), customerName: String(row.customerName || ''), customerPhone: normalizeIndianCustomerPhone_(row.customerPhone), deliveryAddress: String(row.deliveryAddress || ''), orderNotes: String(row.orderNotes || ''), deliveryCharge: roundMoney_(Number(row.deliveryCharge) || 0), roundOff: roundMoney_(Number(row.roundOff) || 0), itemsJson: String(row.itemsJson || '[]'), itemsSummary: String(row.itemsSummary || ''), subtotal: roundMoney_(Number(row.subtotal) || 0), gstPercent: row.gstPercent === '' || row.gstPercent == null ? configuredGstPercent : (Number(row.gstPercent) || 0), cgstAmount: roundMoney_(Number(row.cgstAmount) || 0), sgstAmount: roundMoney_(Number(row.sgstAmount) || 0), gstAmount: roundMoney_(Number(row.gstAmount) || 0), total: roundMoney_(Number(row.total) || 0), status: status, orderType: String(row.orderType || (row.tableId ? 'TABLE' : 'DELIVERY')).toUpperCase(), discountType: String(row.discountType || 'NONE').toUpperCase(), discountValue: roundMoney_(Number(row.discountValue) || 0), discountAmount: roundMoney_(Number(row.discountAmount) || 0), createdAt: createdAt, updatedAt: posDateText_(row.updatedAt), billPrintedAt: posDateText_(row.billPrintedAt), settledAt: posDateText_(row.settledAt), cancelledAt: posDateText_(row.cancelledAt), cancelReason: String(row.cancelReason || ''), mergedIntoOrderId: String(row.mergedIntoOrderId || ''), mergedAt: posDateText_(row.mergedAt), payments: paymentsByOrder[String(row.orderId)] || [] }; try { order.items = JSON.parse(order.itemsJson || '[]'); } catch (e) { order.items = []; } return order; }) .sort((a, b) => { const n = (Number(b.orderNumber) || 0) - (Number(a.orderNumber) || 0); return n || (posDateMs_(b.createdAt) - posDateMs_(a.createdAt)); }); return { tables: tables, orders: orders, activeOrders: orders.filter(order => ['OPEN', 'BILL_PRINTED'].includes(order.status)), // All saved POS orders stay visible in the history; cancelled rows are retained for audit. history: orders.slice(0, 300) }; } function readPosSheetObjects_(sheet, expectedHeaders) { const values = sheet.getDataRange().getValues(); if (values.length < 2) return []; const headers = headerMapFromRow_(values[0]); return values.slice(1).map(row => { const obj = {}; expectedHeaders.forEach(key => { const index = headers[String(key).replace(/\s+/g, '').toLowerCase()]; obj[key] = index === undefined ? '' : row[index]; }); return obj; }).filter(obj => expectedHeaders.some(key => obj[key] !== '' && obj[key] != null)); } function posDateText_(value) { if (value instanceof Date && !isNaN(value.getTime())) { return Utilities.formatDate(value, Session.getScriptTimeZone(), 'dd MMM yyyy, hh:mm a'); } return String(value || ''); } function posDateMs_(value) { const parsed = Date.parse(String(value || '')); return Number.isFinite(parsed) ? parsed : 0; } function ensurePosOrderNumbers_(sheet) { const lastRow = sheet.getLastRow(); if (lastRow < 2) return; const headers = getHeaderMap_(sheet); const orderIdColumn = headers.orderid; const numberColumn = headers.ordernumber; if (orderIdColumn === undefined || numberColumn === undefined) return; const width = sheet.getLastColumn(); const rows = sheet.getRange(2, 1, lastRow - 1, width).getValues(); let maxNumber = rows.reduce((max, row) => { const value = Number(row[numberColumn]); return Number.isFinite(value) && value > max ? value : max; }, 0); let changed = false; rows.forEach(row => { if (row[orderIdColumn] && !(Number(row[numberColumn]) > 0)) { maxNumber += 1; row[numberColumn] = maxNumber; changed = true; } }); if (changed) { const values = rows.map(row => [row[numberColumn]]); sheet.getRange(2, numberColumn + 1, values.length, 1).setValues(values); } } function createPosTable_(tableName) { assertOwner_(); const name = cleanText_(tableName, 40); if (!name) throw new Error('Enter a table name.'); const lock = LockService.getScriptLock(); lock.waitLock(10000); try { const sheets = ensurePosSheets_(); const existing = readPosSheetObjects_(sheets.tables, POS_APP.TABLE_HEADERS); if (existing.some(row => String(row.tableName || '').trim().toLowerCase() === name.toLowerCase() && String(row.active).toLowerCase() !== 'false' )) { throw new Error('A table with this name already exists.'); } const now = new Date(); const table = { tableId: Utilities.getUuid(), tableName: name, createdAt: now, active: true }; sheets.tables.appendRow([ table.tableId, table.tableName, table.createdAt, table.active ]); return { tableId: table.tableId, tableName: table.tableName, createdAt: posDateText_(now) }; } finally { lock.releaseLock(); } } function renamePosTable_(payload) { assertOwner_(); payload = payload || {}; const tableId = cleanText_(payload.tableId, 100); const newName = cleanText_(payload.tableName, 40); if (!tableId || !newName) throw new Error('Table and new name are required.'); const lock = LockService.getScriptLock(); lock.waitLock(10000); try { const sheets = ensurePosSheets_(); const tableValues = sheets.tables.getDataRange().getValues(); if (tableValues.length < 2) throw new Error('Table was not found.'); const tableHeaders = headerMapFromRow_(tableValues[0]); const tableRows = tableValues.slice(1); const tableIndex = tableRows.findIndex(row => String(row[tableHeaders.tableid] || '') === tableId && String(row[tableHeaders.active]).toLowerCase() !== 'false'); if (tableIndex < 0) throw new Error('Active table was not found.'); const duplicate = tableRows.some((row, index) => index !== tableIndex && String(row[tableHeaders.active]).toLowerCase() !== 'false' && String(row[tableHeaders.tablename] || '').trim().toLowerCase() === newName.toLowerCase()); if (duplicate) throw new Error('Another active table already uses that name.'); const orderRows = readPosSheetObjects_(sheets.orders, POS_APP.ORDER_HEADERS); const activeOrder = orderRows.find(row => String(row.tableId) === tableId && ['OPEN','BILL_PRINTED'].includes(String(row.status).toUpperCase())); if (activeOrder && String(activeOrder.status).toUpperCase() === 'BILL_PRINTED') { throw new Error('A bill-printed table cannot be renamed until payment is settled.'); } const rowNumber = tableIndex + 2; sheets.tables.getRange(rowNumber, tableHeaders.tablename + 1).setValue(newName); if (activeOrder) { const all = sheets.orders.getDataRange().getValues(); const h = headerMapFromRow_(all[0]); const idx = all.slice(1).findIndex(row => String(row[h.orderid] || '') === String(activeOrder.orderId)); if (idx >= 0) sheets.orders.getRange(idx + 2, h.tablename + 1).setValue(newName); } return {ok:true, tableId:tableId, tableName:newName}; } finally { lock.releaseLock(); } } function deletePosTable_(tableId) { assertOwner_(); const id = cleanText_(tableId, 100); if (!id) throw new Error('Table was not specified.'); const lock = LockService.getScriptLock(); lock.waitLock(10000); try { const sheets = ensurePosSheets_(); const tableValues = sheets.tables.getDataRange().getValues(); if (tableValues.length < 2) throw new Error('Table was not found.'); const headers = headerMapFromRow_(tableValues[0]); const rows = tableValues.slice(1); const index = rows.findIndex(row => String(row[headers.tableid] || '') === id && String(row[headers.active]).toLowerCase() !== 'false'); if (index < 0) throw new Error('Active table was not found.'); const orders = readPosSheetObjects_(sheets.orders, POS_APP.ORDER_HEADERS); if (orders.some(order => String(order.tableId) === id && ['OPEN','BILL_PRINTED'].includes(String(order.status).toUpperCase()))) { throw new Error('This table has an active order. Settle the bill or merge the order before deleting the table.'); } // Soft-delete only: retain the row for audit/history and any old receipts. sheets.tables.getRange(index + 2, headers.active + 1).setValue(false); return {ok:true, tableId:id}; } finally { lock.releaseLock(); } } function mergePosTables_(payload) { assertOwner_(); payload = payload || {}; const sourceId = cleanText_(payload.sourceTableId, 100); const targetId = cleanText_(payload.targetTableId, 100); if (!sourceId || !targetId || sourceId === targetId) throw new Error('Select two different tables.'); const lock = LockService.getScriptLock(); lock.waitLock(10000); try { const sheets = ensurePosSheets_(); const tableRows = readPosSheetObjects_(sheets.tables, POS_APP.TABLE_HEADERS).filter(row => String(row.active).toLowerCase() !== 'false'); const sourceTable = tableRows.find(row => String(row.tableId) === sourceId); const targetTable = tableRows.find(row => String(row.tableId) === targetId); if (!sourceTable || !targetTable) throw new Error('Source or target table is no longer active. Refresh and try again.'); const values = sheets.orders.getDataRange().getValues(); if (values.length < 2) throw new Error('No active order can be merged.'); const headers = headerMapFromRow_(values[0]); const rows = values.slice(1); const entries = rows.map((row, index) => ({row:row, rowNumber:index + 2})); const activeForTable = id => entries.filter(entry => String(entry.row[headers.tableid] || '') === id && ['OPEN','BILL_PRINTED'].includes(String(entry.row[headers.status] || '').toUpperCase())); const sourceEntries = activeForTable(sourceId); const targetEntries = activeForTable(targetId); if (sourceEntries.length !== 1 || String(sourceEntries[0].row[headers.status]).toUpperCase() !== 'OPEN') { throw new Error('Source table must have one open order. Bill Printed tables cannot be merged.'); } if (targetEntries.length > 1 || (targetEntries.length && String(targetEntries[0].row[headers.status]).toUpperCase() !== 'OPEN')) { throw new Error('Target table must be available or have an open order. Bill Printed tables cannot be merge targets.'); } const sourceEntry = sourceEntries[0]; const sourceRow = sourceEntry.row; let movedOrderId = String(sourceRow[headers.orderid] || ''); let targetOrderId = movedOrderId; const sourceItems = (() => { try { return JSON.parse(String(sourceRow[headers.itemsjson] || '[]')); } catch(e) { return []; } })(); if (!sourceItems.length) throw new Error('Source order has no saved items.'); if (targetEntries.length) { const targetEntry = targetEntries[0]; const targetRow = targetEntry.row; targetOrderId = String(targetRow[headers.orderid] || ''); const targetItems = (() => { try { return JSON.parse(String(targetRow[headers.itemsjson] || '[]')); } catch(e) { return []; } })(); const byId = new Map(); targetItems.concat(sourceItems).forEach(item => { const id = String(item.id || item.name || ''); if (!id) return; const existing = byId.get(id); if (existing) existing.quantity = (Number(existing.quantity) || 0) + (Number(item.quantity) || 0); else byId.set(id, Object.assign({}, item, {quantity:Number(item.quantity) || 0})); }); const combined = Array.from(byId.values()).map(item => Object.assign({}, item, { lineTotal: roundMoney_((Number(item.unitPrice) || 0) * (Number(item.quantity) || 0)) })); const subtotal = roundMoney_(combined.reduce((sum, item) => sum + Number(item.lineTotal || 0), 0)); const delivery = 0; const tax = calculatePosOrderTax_(combined, subtotal, 0, delivery, getPosGstPercent_()); const notes = [String(targetRow[headers.ordernotes] || '').trim(), String(sourceRow[headers.ordernotes] || '').trim(), 'Merged from ' + sourceTable.tableName].filter(Boolean).join(' | ').slice(0,500); const mergedFields = { tableId:targetId, tableName:String(targetTable.tableName), itemsJson:JSON.stringify(combined), itemsSummary:combined.map(item => (Number(item.quantity)||0) + '× ' + item.name).join(', '), subtotal:subtotal, gstPercent:tax.gstPercent, cgstAmount:tax.cgstAmount, sgstAmount:tax.sgstAmount, gstAmount:tax.gstAmount, total:tax.total, roundOff:tax.roundOff, orderNotes:notes, updatedAt:new Date(), customerName:String(targetRow[headers.customername] || sourceRow[headers.customername] || ''), customerPhone:String(targetRow[headers.customerphone] || sourceRow[headers.customerphone] || '') }; writePosOrderFields_(sheets.orders, targetEntry.rowNumber, mergedFields); writePosOrderFields_(sheets.orders, sourceEntry.rowNumber, { status:'MERGED', mergedIntoOrderId:targetOrderId, mergedAt:new Date(), cancelReason:'Merged from ' + sourceTable.tableName + ' into ' + targetTable.tableName, updatedAt:new Date() }); } else { // Move the existing source order to the available target table. Its saved // items remain the KOT quantity baseline, so the next KOT prints only new adds. writePosOrderFields_(sheets.orders, sourceEntry.rowNumber, { tableId:targetId, tableName:String(targetTable.tableName), updatedAt:new Date(), orderNotes:[String(sourceRow[headers.ordernotes] || '').trim(), 'Moved from ' + sourceTable.tableName].filter(Boolean).join(' | ').slice(0,500) }); } return {ok:true, sourceTableId:sourceId, targetTableId:targetId, orderId:movedOrderId, targetOrderId:targetOrderId}; } finally { lock.releaseLock(); } } function writePosOrderFields_(sheet, rowNumber, fields) { const headers = getHeaderMap_(sheet); const width = sheet.getLastColumn(); const row = rowNumber ? sheet.getRange(rowNumber, 1, 1, width).getValues()[0] : Array(width).fill(''); Object.keys(fields).forEach(key => { const column = headers[String(key).replace(/\s+/g, '').toLowerCase()]; if (column !== undefined) row[column] = fields[key]; }); if (rowNumber) { sheet.getRange(rowNumber, 1, 1, width).setValues([row]); return rowNumber; } sheet.appendRow(row); return sheet.getLastRow(); } function getPosGstPercent_(settings) { settings = settings || getSettings_(); if (String(settings.gstRegistrationType || 'UNREGISTERED').toUpperCase() !== 'REGULAR') return 0; const rate = Number(settings.gstPercent); return Number.isFinite(rate) && rate >= 0 && rate <= 28 ? rate : 0; } function calculatePosTax_(taxableValue, gstPercent) { const taxable = roundMoney_(Math.max(0, Number(taxableValue) || 0)); const rate = Number(gstPercent) || 0; const gstAmount = roundMoney_(taxable * rate / 100); const cgstAmount = roundMoney_(gstAmount / 2); const sgstAmount = roundMoney_(gstAmount - cgstAmount); const beforeRoundOff = roundMoney_(taxable + gstAmount); const total = Math.round(beforeRoundOff); const roundOff = roundMoney_(total - beforeRoundOff); return { taxableValue: taxable, gstPercent: rate, cgstAmount: cgstAmount, sgstAmount: sgstAmount, gstAmount: gstAmount, roundOff: roundOff, total: total }; } function calculatePosOrderTax_(items, subtotal, discountAmount, deliveryCharge, gstPercent) { const cleanSubtotal = roundMoney_(Math.max(0, Number(subtotal) || 0)); const discount = Math.min(cleanSubtotal, Math.max(0, Number(discountAmount) || 0)); const delivery = roundMoney_(Math.max(0, Number(deliveryCharge) || 0)); const gstEligibleGross = (items || []).reduce((sum, item) => { const applicable = String(item.gstApplicable == null ? 'Yes' : item.gstApplicable).toLowerCase() !== 'no'; return sum + (applicable ? roundMoney_(Number(item.lineTotal) || (Number(item.unitPrice) || 0) * (Number(item.quantity) || 0)) : 0); }, 0); const eligibleDiscount = cleanSubtotal > 0 ? Math.min(gstEligibleGross, discount * (gstEligibleGross / cleanSubtotal)) : 0; const taxableValue = roundMoney_(Math.max(0, gstEligibleGross - eligibleDiscount) + delivery); const rate = Math.max(0, Number(gstPercent) || 0); const gstAmount = roundMoney_(taxableValue * rate / 100); const cgstAmount = roundMoney_(gstAmount / 2); const sgstAmount = roundMoney_(gstAmount - cgstAmount); const netBeforeTax = roundMoney_(Math.max(0, cleanSubtotal - discount) + delivery); const beforeRoundOff = roundMoney_(netBeforeTax + gstAmount); const total = Math.round(beforeRoundOff); const roundOff = roundMoney_(total - beforeRoundOff); return { taxableValue: taxableValue, gstEligibleSubtotal: roundMoney_(gstEligibleGross), gstPercent: rate, cgstAmount: cgstAmount, sgstAmount: sgstAmount, gstAmount: gstAmount, roundOff: roundOff, total: total }; } function savePosOrder_(payload) { assertOwner_(); payload = payload || {}; const orderType = String(payload.orderType || 'TABLE').toUpperCase() === 'DELIVERY' ? 'DELIVERY' : 'TABLE'; const tableId = orderType === 'DELIVERY' ? '' : cleanText_(payload.tableId, 100); const requestedOrderId = cleanText_(payload.orderId, 120); const customerName = cleanText_(payload.customerName, 100); const customerPhoneInput = String(payload.customerPhone || '').trim(); const customerPhone = normalizeIndianCustomerPhone_(customerPhoneInput); const deliveryAddress = cleanText_(payload.deliveryAddress, 500); const orderNotes = cleanText_(payload.orderNotes, 500); const deliveryChargeInput = Number(payload.deliveryCharge || 0); if (!Number.isFinite(deliveryChargeInput) || deliveryChargeInput < 0 || deliveryChargeInput > 100000) { throw new Error('Enter a valid delivery charge between ₹0 and ₹100,000.'); } const deliveryCharge = orderType === 'DELIVERY' ? roundMoney_(deliveryChargeInput) : 0; if (customerPhoneInput && !customerPhone) { throw new Error('Enter a valid 10-digit Indian mobile number. +91, spaces and hyphens are accepted.'); } if (orderType === 'TABLE' && !tableId) throw new Error('Select a table first.'); if (!Array.isArray(payload.items) || !payload.items.length) { throw new Error('Add at least one menu item.'); } if (payload.items.length > 100) throw new Error('Too many different items in one order.'); const lock = LockService.getScriptLock(); lock.waitLock(10000); try { const sheets = ensurePosSheets_(); const table = orderType === 'TABLE' ? readPosSheetObjects_(sheets.tables, POS_APP.TABLE_HEADERS).find(row => String(row.tableId) === tableId && String(row.active).toLowerCase() !== 'false') : null; if (orderType === 'TABLE' && !table) throw new Error('This table no longer exists. Refresh and try again.'); const currentSettings = getSettings_(); const menu = readMenuItems_(currentSettings); const posGstRate = getPosGstPercent_(currentSettings); const menuById = new Map(menu.map(item => [String(item.id), item])); const savedItems = payload.items.map(input => { const id = cleanText_(input && input.id, 100); const item = menuById.get(id); if (!item) throw new Error('A menu item is no longer available. Refresh the POS menu.'); if (String(item.availability || 'Yes').toLowerCase() === 'no') throw new Error(item.name + ' is out of stock.'); const quantity = Number(input.quantity); if (!Number.isInteger(quantity) || quantity < 1 || quantity > 500) throw new Error('Invalid quantity for ' + item.name + '.'); const price = roundMoney_(Number(item.price) || 0); return { id: item.id, name: item.name, category: item.category, foodtype: item.foodtype || '', gstApplicable: String(item.gstApplicable == null ? 'Yes' : item.gstApplicable).toLowerCase() !== 'no', quantity: quantity, unitPrice: price, lineTotal: roundMoney_(price * quantity) }; }); const allOrders = readPosSheetObjects_(sheets.orders, POS_APP.ORDER_HEADERS); let activeEntry = null; if (requestedOrderId) { activeEntry = allOrders .map((row, index) => ({ row: row, rowNumber: index + 2 })) .find(entry => String(entry.row.orderId) === requestedOrderId && !['SETTLED', 'CANCELLED'].includes(String(entry.row.status).toUpperCase()) ); if (!activeEntry) throw new Error('This active POS order was not found. Refresh Billing and reopen it.'); } else if (orderType === 'TABLE') { activeEntry = allOrders .map((row, index) => ({ row: row, rowNumber: index + 2 })) .find(entry => String(entry.row.tableId) === tableId && !['SETTLED', 'CANCELLED'].includes(String(entry.row.status).toUpperCase()) ); } if (activeEntry && String(activeEntry.row.orderType || (activeEntry.row.tableId ? 'TABLE' : 'DELIVERY')).toUpperCase() !== orderType) { throw new Error('Order type does not match the selected POS order.'); } const previousItems = (() => { try { return JSON.parse(activeEntry ? String(activeEntry.row.itemsJson || '[]') : '[]'); } catch (e) { return []; } })(); const previousQuantities = new Map(previousItems.map(item => [String(item.id), Number(item.quantity) || 0])); const kotItems = savedItems.reduce((added, item) => { const addedQuantity = Math.max(0, item.quantity - (previousQuantities.get(String(item.id)) || 0)); if (addedQuantity > 0) { added.push({ id: item.id, name: item.name, quantity: addedQuantity, unitPrice: item.unitPrice, lineTotal: roundMoney_(item.unitPrice * addedQuantity) }); } return added; }, []); const subtotal = roundMoney_(savedItems.reduce((sum, item) => sum + item.lineTotal, 0)); const printBill = payload.printBill === true; const discountType = printBill ? String(payload.discountType || 'NONE').toUpperCase() : 'NONE'; const discountValue = printBill ? roundMoney_(Number(payload.discountValue) || 0) : 0; if (!['NONE', 'PERCENT', 'AMOUNT'].includes(discountType)) throw new Error('Invalid discount type.'); if (discountType === 'PERCENT' && discountValue > 100) throw new Error('Discount percentage cannot exceed 100%.'); if (discountType === 'AMOUNT' && discountValue > subtotal) throw new Error('Discount cannot exceed the subtotal.'); const discountAmount = discountType === 'PERCENT' ? roundMoney_(subtotal * discountValue / 100) : (discountType === 'AMOUNT' ? discountValue : 0); const gst = calculatePosOrderTax_(savedItems, subtotal, discountAmount, deliveryCharge, posGstRate); const now = new Date(); const orderId = activeEntry ? String(activeEntry.row.orderId) : 'POS-' + Utilities.getUuid().replace(/-/g, '').slice(0, 14).toUpperCase(); const orderNumber = activeEntry ? (Number(activeEntry.row.orderNumber) || 1) : (allOrders.reduce((max, row) => Math.max(max, Number(row.orderNumber) || 0), 0) + 1); const createdAt = activeEntry ? activeEntry.row.createdAt : now; const tableName = orderType === 'DELIVERY' ? 'Delivery Order' : String(table.tableName); const summary = savedItems.map(item => item.quantity + '× ' + item.name).join(', '); const fields = { orderId: orderId, orderNumber: orderNumber, tableId: tableId, tableName: tableName, customerName: customerName, customerPhone: customerPhone, deliveryAddress: deliveryAddress, orderNotes: orderNotes, deliveryCharge: deliveryCharge, roundOff: gst.roundOff, itemsJson: JSON.stringify(savedItems), itemsSummary: summary, subtotal: subtotal, gstPercent: gst.gstPercent, cgstAmount: gst.cgstAmount, sgstAmount: gst.sgstAmount, gstAmount: gst.gstAmount, total: gst.total, status: printBill ? 'BILL_PRINTED' : 'OPEN', createdAt: createdAt, updatedAt: now, billPrintedAt: printBill ? now : '', settledAt: '', cancelledAt: '', cancelReason: '', orderType: orderType, discountType: discountType, discountValue: discountValue, discountAmount: discountAmount }; writePosOrderFields_(sheets.orders, activeEntry ? activeEntry.rowNumber : 0, fields); return { orderId: orderId, orderNumber: orderNumber, tableId: tableId, tableName: tableName, customerName: customerName, customerPhone: customerPhone, deliveryAddress: deliveryAddress, orderNotes: orderNotes, deliveryCharge: deliveryCharge, roundOff: gst.roundOff, orderType: orderType, items: savedItems, kotItems: kotItems, itemsJson: JSON.stringify(savedItems), itemsSummary: summary, subtotal: subtotal, gstPercent: gst.gstPercent, cgstAmount: gst.cgstAmount, sgstAmount: gst.sgstAmount, gstAmount: gst.gstAmount, total: gst.total, status: printBill ? 'BILL_PRINTED' : 'OPEN', discountType: discountType, discountValue: discountValue, discountAmount: discountAmount, createdAt: posDateText_(createdAt), updatedAt: posDateText_(now), billPrintedAt: printBill ? posDateText_(now) : '', settledAt: '', payments: [] }; } finally { lock.releaseLock(); } } function markPosBillPrinted_(payload) { assertOwner_(); payload = typeof payload === 'string' ? { orderId: payload } : (payload || {}); const id = cleanText_(payload.orderId, 120); const discountType = String(payload.discountType || 'NONE').trim().toUpperCase(); const discountValue = roundMoney_(Number(payload.discountValue) || 0); if (!id) throw new Error('Missing POS order ID.'); if (!['NONE', 'PERCENT', 'AMOUNT'].includes(discountType)) throw new Error('Invalid discount type.'); if (!Number.isFinite(discountValue) || discountValue < 0) throw new Error('Enter a valid discount.'); const lock = LockService.getScriptLock(); lock.waitLock(10000); try { const sheets = ensurePosSheets_(); const values = sheets.orders.getDataRange().getValues(); if (values.length < 2) throw new Error('POS order was not found.'); const headers = headerMapFromRow_(values[0]); const index = values.slice(1).findIndex(row => String(row[headers.orderid] || '') === id); if (index < 0) throw new Error('POS order was not found.'); const rowNumber = index + 2; const row = values[rowNumber - 1]; const currentStatus = String(row[headers.status] || '').toUpperCase(); if (['SETTLED', 'CANCELLED'].includes(currentStatus)) throw new Error('This order is already closed.'); const subtotal = roundMoney_(Number(row[headers.subtotal]) || 0); const deliveryChargeColumn = headers.deliverycharge; const deliveryCharge = deliveryChargeColumn === undefined ? 0 : roundMoney_(Number(row[deliveryChargeColumn]) || 0); if (discountType === 'PERCENT' && discountValue > 100) throw new Error('Discount percentage cannot exceed 100%.'); if (discountType === 'AMOUNT' && discountValue > subtotal) throw new Error('Discount cannot exceed the subtotal.'); let discountAmount = 0; if (discountType === 'PERCENT') discountAmount = roundMoney_(subtotal * discountValue / 100); else if (discountType === 'AMOUNT') discountAmount = roundMoney_(discountValue); const configuredRate = getPosGstPercent_(); const gstRate = headers.gstpercent === undefined || row[headers.gstpercent] === '' || row[headers.gstpercent] == null ? configuredRate : Math.max(0, Number(row[headers.gstpercent]) || 0); let savedItems = []; try { savedItems = JSON.parse(String(row[headers.itemsjson] || '[]')); } catch (error) { savedItems = []; } const gst = calculatePosOrderTax_(savedItems, subtotal, discountAmount, deliveryCharge, gstRate); const now = new Date(); writePosOrderFields_(sheets.orders, rowNumber, { status: 'BILL_PRINTED', updatedAt: now, billPrintedAt: now, discountType: discountType, discountValue: discountValue, discountAmount: discountAmount, gstPercent: gst.gstPercent, cgstAmount: gst.cgstAmount, sgstAmount: gst.sgstAmount, gstAmount: gst.gstAmount, deliveryCharge: deliveryCharge, roundOff: gst.roundOff, total: gst.total }); return { ok: true, orderId: id, orderNumber: Number(row[headers.ordernumber]) || 0, status: 'BILL_PRINTED', billPrintedAt: posDateText_(now), discountType: discountType, discountValue: discountValue, discountAmount: discountAmount, subtotal: subtotal, deliveryCharge: deliveryCharge, roundOff: gst.roundOff, gstPercent: gst.gstPercent, cgstAmount: gst.cgstAmount, sgstAmount: gst.sgstAmount, gstAmount: gst.gstAmount, total: gst.total }; } finally { lock.releaseLock(); } } function cancelPosOrder_(payload) { assertOwner_(); payload = payload || {}; const orderId = cleanText_(payload.orderId, 120); const reason = cleanText_(payload.reason || 'Cancelled by owner', 200); if (!orderId) throw new Error('Missing POS order ID.'); const lock = LockService.getScriptLock(); lock.waitLock(10000); try { const sheets = ensurePosSheets_(); const values = sheets.orders.getDataRange().getValues(); if (values.length < 2) throw new Error('POS order was not found.'); const headers = headerMapFromRow_(values[0]); const index = values.slice(1).findIndex(row => String(row[headers.orderid] || '') === orderId); if (index < 0) throw new Error('POS order was not found.'); const rowNumber = index + 2; const row = values[rowNumber - 1]; const status = String(row[headers.status] || '').toUpperCase(); if (status === 'SETTLED') { throw new Error('This order is already paid. Use a separate refund process instead of cancelling a settled payment.'); } if (status === 'CANCELLED') return { ok: true, orderId: orderId, status: 'CANCELLED' }; const now = new Date(); writePosOrderFields_(sheets.orders, rowNumber, { status: 'CANCELLED', updatedAt: now, cancelledAt: now, cancelReason: reason }); return { ok: true, orderId: orderId, status: 'CANCELLED', cancelledAt: posDateText_(now) }; } finally { lock.releaseLock(); } } function settlePosOrder_(payload) { assertOwner_(); payload = payload || {}; const orderId = cleanText_(payload.orderId, 120); const payments = Array.isArray(payload.payments) ? payload.payments : []; if (!orderId) throw new Error('Missing order ID.'); if (!payments.length || payments.length > 3) throw new Error('Select a valid payment method.'); const lock = LockService.getScriptLock(); lock.waitLock(10000); try { const sheets = ensurePosSheets_(); const values = sheets.orders.getDataRange().getValues(); if (values.length < 2) throw new Error('POS order was not found.'); const headers = headerMapFromRow_(values[0]); const rowIndex = values.slice(1).findIndex(row => String(row[headers.orderid] || '') === orderId); if (rowIndex < 0) throw new Error('POS order was not found.'); const rowNumber = rowIndex + 2; const row = values[rowNumber - 1]; const status = String(row[headers.status] || '').toUpperCase(); if (status === 'SETTLED') throw new Error('This order is already settled.'); if (status === 'CANCELLED') throw new Error('This order has been cancelled.'); if (status !== 'BILL_PRINTED') throw new Error('Print the bill before settling payment.'); const total = roundMoney_(Number(row[headers.total]) || 0); const cleanPayments = payments.map(payment => { const method = String(payment && payment.method || '').trim().toUpperCase(); const amount = roundMoney_(Number(payment && payment.amount)); if (!['CASH', 'UPI', 'CARD'].includes(method)) throw new Error('Invalid payment method.'); if (!Number.isFinite(amount) || amount <= 0) throw new Error('Enter a valid amount for each payment method.'); return { method: method, amount: amount }; }); const uniqueMethods = new Set(cleanPayments.map(item => item.method)); if (uniqueMethods.size !== cleanPayments.length) throw new Error('Use each payment method only once in split payment.'); const paid = roundMoney_(cleanPayments.reduce((sum, item) => sum + item.amount, 0)); if (Math.abs(paid - total) > 0.01) { throw new Error('Payment total ₹' + paid + ' must equal bill total ₹' + total + '.'); } const now = new Date(); const tableId = String(row[headers.tableid] || ''); const tableName = String(row[headers.tablename] || ''); cleanPayments.forEach(payment => { sheets.payments.appendRow([ Utilities.getUuid(), orderId, tableId, tableName, payment.method, payment.amount, now ]); }); row[headers.status] = 'SETTLED'; row[headers.updatedat] = now; row[headers.settledat] = now; sheets.orders.getRange(rowNumber, 1, 1, row.length).setValues([row]); return { ok: true, orderId: orderId, status: 'SETTLED', total: total, payments: cleanPayments, settledAt: posDateText_(now) }; } finally { lock.releaseLock(); } } // ========================================================= // TRENDS + CUSTOMER OFFERS // Eligibility and offer-given actions use settled POS payments only. // QR requests are intentionally excluded because a saved WhatsApp request // does not prove payment or order completion. // ========================================================= const OFFER_APP = { OFFERS_SHEET: 'POSOffers', GRANTS_SHEET: 'POSOfferGrants', OFFER_HEADERS: ['offerId','offerName','minSpend','minVisits','rewardType','rewardPercent','rewardAmount','complimentaryItemId','complimentaryItemName','active','createdAt','updatedAt','rewardMode'], GRANT_HEADERS: ['grantId','offerId','customerName','customerPhone','grantStatus','qualifyingSpend','qualifyingVisits','rewardType','rewardPercent','rewardAmount','complimentaryItemId','complimentaryItemName','grantedAt','rewardMode'] }; function ensureOfferSheets_() { const ss = getConfigSpreadsheet_(); return { offers: ensureSheetWithHeaders_(ss, OFFER_APP.OFFERS_SHEET, OFFER_APP.OFFER_HEADERS), grants: ensureSheetWithHeaders_(ss, OFFER_APP.GRANTS_SHEET, OFFER_APP.GRANT_HEADERS) }; } function readOffers_() { const sheets = ensureOfferSheets_(); return readPosSheetObjects_(sheets.offers, OFFER_APP.OFFER_HEADERS).map(row => ({ offerId:String(row.offerId || ''), offerName:String(row.offerName || ''), minSpend:roundMoney_(Number(row.minSpend) || 0), minVisits:Number(row.minVisits) || 0, rewardType:String(row.rewardType || 'PERCENT').toUpperCase(), rewardPercent:Number(row.rewardPercent) || 0, rewardAmount:roundMoney_(Number(row.rewardAmount) || 0), complimentaryItemId:String(row.complimentaryItemId || ''), complimentaryItemName:String(row.complimentaryItemName || ''), rewardMode:(function(){ const saved=String(row.rewardMode || '').toUpperCase(); if (['DISCOUNT_ONLY','COMPLIMENTARY_ONLY','BOTH'].includes(saved)) return saved; if (row.complimentaryItemId) return String(row.rewardType || '').toUpperCase()==='NONE' ? 'COMPLIMENTARY_ONLY' : 'BOTH'; return 'DISCOUNT_ONLY'; })(), active:!(row.active === false || String(row.active).toLowerCase() === 'false'), createdAt:posDateText_(row.createdAt), updatedAt:posDateText_(row.updatedAt) })).sort((a,b)=>posDateMs_(b.createdAt)-posDateMs_(a.createdAt)); } function getSettledCustomerMetrics_() { const sheet = ensurePosSheets_().orders; const rows = readPosSheetObjects_(sheet, POS_APP.ORDER_HEADERS); const customers = new Map(); rows.forEach(row => { if (String(row.status || '').toUpperCase() !== 'SETTLED') return; const phone = normalizeIndianCustomerPhone_(row.customerPhone); if (!phone) return; const name = cleanText_(row.customerName, 100); const dateKey = financeDateKey_(row.settledAt || row.createdAt); const current = customers.get(phone) || {phone:phone,name:name,spend:0,orderCount:0,visits:new Set(),lastVisit:''}; current.spend = roundMoney_(current.spend + (Number(row.total) || 0)); current.orderCount += 1; if (dateKey) current.visits.add(dateKey); if (dateKey && dateKey > current.lastVisit) current.lastVisit = dateKey; if (!current.name && name) current.name = name; customers.set(phone,current); }); // Also surface customers known from the QR/WhatsApp Orders sheet. Their // request records do not count toward spend or visits until a POS bill is settled. const settings = getSettings_(); readOrders_(settings).forEach(order => { const phone = normalizeIndianCustomerPhone_(order.customerPhone); if (!phone) return; const name = cleanText_(order.customerName, 100); const current = customers.get(phone); if (current) { if (!current.name && name) current.name = name; return; } customers.set(phone, {phone:phone,name:name,spend:0,orderCount:0,visits:new Set(),lastVisit:''}); }); return Array.from(customers.values()).map(row => ({ phone:row.phone,name:row.name,spend:row.spend,orderCount:row.orderCount, visits:row.visits.size,lastVisit:row.lastVisit })); } function getOfferProgress_(offer, customer, grantPhones) { const spendRequired = Number(offer.minSpend) || 0; const visitsRequired = Number(offer.minVisits) || 0; const spendOk = spendRequired <= 0 || customer.spend >= spendRequired; const visitsOk = visitsRequired <= 0 || customer.visits >= visitsRequired; const spendProgress = spendRequired > 0 ? Math.min(100, customer.spend / spendRequired * 100) : 100; const visitProgress = visitsRequired > 0 ? Math.min(100, customer.visits / visitsRequired * 100) : 100; return Object.assign({}, customer, { eligible:spendOk && visitsOk, granted:grantPhones.has(customer.phone), spendProgress:spendProgress, visitProgress:visitProgress, progressPercent:Math.min(spendProgress, visitProgress) }); } function createOffer_(payload) { assertOwner_(); payload = payload || {}; const offerName = cleanText_(payload.offerName, 100); const minSpend = Number(payload.minSpend || 0); const minVisits = Number(payload.minVisits || 0); const requestedMode = String(payload.rewardMode || '').toUpperCase(); const rewardTypeInput = String(payload.rewardType || 'PERCENT').toUpperCase(); const rewardPercentInput = Number(payload.rewardPercent || 0); const rewardAmountInput = Number(payload.rewardAmount || 0); const requestedComplimentaryId = cleanText_(payload.complimentaryItemId, 100); if (!offerName) throw new Error('Offer name is required.'); if (!Number.isFinite(minSpend) || minSpend < 0 || minSpend > 100000000) throw new Error('Enter a valid minimum spend.'); if (!Number.isInteger(minVisits) || minVisits < 0 || minVisits > 365) throw new Error('Enter a valid visit milestone.'); if (minSpend <= 0 && minVisits <= 0) throw new Error('Set at least one eligibility milestone: spend or visits.'); // Older clients did not send rewardMode. Infer their previous behavior: // discount + complimentary item = BOTH; otherwise discount only. const rewardMode = ['DISCOUNT_ONLY','COMPLIMENTARY_ONLY','BOTH'].includes(requestedMode) ? requestedMode : (requestedComplimentaryId ? 'BOTH' : 'DISCOUNT_ONLY'); const discountEnabled = rewardMode === 'DISCOUNT_ONLY' || rewardMode === 'BOTH'; const complimentaryEnabled = rewardMode === 'COMPLIMENTARY_ONLY' || rewardMode === 'BOTH'; let rewardType = 'NONE'; let rewardPercent = 0; let rewardAmount = 0; if (discountEnabled) { rewardType = rewardTypeInput; if (!['PERCENT','AMOUNT','LOWER_OF'].includes(rewardType)) throw new Error('Select a valid discount type.'); if (['PERCENT','LOWER_OF'].includes(rewardType) && (!Number.isFinite(rewardPercentInput) || rewardPercentInput <= 0 || rewardPercentInput > 100)) throw new Error('Percentage discount must be greater than 0 and at most 100%.'); if (['AMOUNT','LOWER_OF'].includes(rewardType) && (!Number.isFinite(rewardAmountInput) || rewardAmountInput <= 0 || rewardAmountInput > 1000000)) throw new Error('Flat discount must be greater than ₹0.'); rewardPercent = ['PERCENT','LOWER_OF'].includes(rewardType) ? rewardPercentInput : 0; rewardAmount = ['AMOUNT','LOWER_OF'].includes(rewardType) ? roundMoney_(rewardAmountInput) : 0; } const complimentaryItemId = complimentaryEnabled ? requestedComplimentaryId : ''; if (complimentaryEnabled && !complimentaryItemId) throw new Error('Select a complimentary dish or drink for this reward type.'); let complimentaryItemName = ''; if (complimentaryItemId) { const menu = readMenuItems_(getSettings_()); const item = menu.find(entry => String(entry.id) === complimentaryItemId && String(entry.availability || 'Yes').toLowerCase() !== 'no'); if (!item) throw new Error('Selected complimentary item is unavailable. Refresh the menu and try again.'); complimentaryItemName = item.name; } const sheets = ensureOfferSheets_(); const now = new Date(); const offerId = 'OFR-' + Utilities.getUuid().replace(/-/g,'').slice(0,12).toUpperCase(); sheets.offers.appendRow([ offerId,offerName,roundMoney_(minSpend),minVisits,rewardType,rewardPercent, rewardAmount,complimentaryItemId,complimentaryItemName,true,now,now,rewardMode ]); return {ok:true,offerId:offerId,rewardMode:rewardMode}; } function setOfferActive_(payload) { assertOwner_(); payload = payload || {}; const offerId = cleanText_(payload.offerId,100); const active = payload.active === true; const sheet = ensureOfferSheets_().offers; const values = sheet.getDataRange().getValues(); if (values.length < 2) throw new Error('Offer was not found.'); const headers = headerMapFromRow_(values[0]); const index = values.slice(1).findIndex(row => String(row[headers.offerid] || '') === offerId); if (index < 0) throw new Error('Offer was not found.'); sheet.getRange(index + 2, headers.active + 1).setValue(active); sheet.getRange(index + 2, headers.updatedat + 1).setValue(new Date()); return {ok:true,active:active}; } function getOffersDashboardData_(payload) { assertOwner_(); payload = payload || {}; const offers = readOffers_(); const activeOffers = offers.filter(offer => offer.active); const selected = activeOffers.find(offer => offer.offerId === String(payload.offerId || '')) || activeOffers[0] || null; const grants = readPosSheetObjects_(ensureOfferSheets_().grants, OFFER_APP.GRANT_HEADERS); const grantedPhones = new Set(selected ? grants.filter(row => String(row.offerId) === selected.offerId && String(row.grantStatus).toUpperCase() === 'GIVEN').map(row => normalizeIndianCustomerPhone_(row.customerPhone)).filter(Boolean) : []); const metrics = getSettledCustomerMetrics_(); const customers = selected ? metrics.map(customer => getOfferProgress_(selected,customer,grantedPhones)).sort((a,b)=>Number(b.eligible)-Number(a.eligible) || b.progressPercent-a.progressPercent || b.spend-a.spend) : []; return {offers:offers,selectedOffer:selected,customers:customers,settledPosOnly:true}; } function markOfferGiven_(payload) { assertOwner_(); payload = payload || {}; const offerId = cleanText_(payload.offerId,100); const phone = normalizeIndianCustomerPhone_(payload.customerPhone); if (!offerId || !phone) throw new Error('Select an offer and a valid customer number.'); const lock = LockService.getScriptLock(); lock.waitLock(10000); try { const offer = readOffers_().find(row => row.offerId === offerId && row.active); if (!offer) throw new Error('This offer is no longer active.'); const sheets = ensureOfferSheets_(); const grants = readPosSheetObjects_(sheets.grants, OFFER_APP.GRANT_HEADERS); if (grants.some(row => String(row.offerId) === offerId && normalizeIndianCustomerPhone_(row.customerPhone) === phone && String(row.grantStatus).toUpperCase() === 'GIVEN')) { throw new Error('This customer has already received this offer.'); } const customer = getSettledCustomerMetrics_().find(row => row.phone === phone); if (!customer) throw new Error('No settled POS purchase history found for this customer.'); const progress = getOfferProgress_(offer,customer,new Set()); if (!progress.eligible) throw new Error('Customer has not reached all offer milestones yet.'); const now = new Date(); sheets.grants.appendRow([ Utilities.getUuid(),offerId,customer.name,phone,'GIVEN',customer.spend,customer.visits, offer.rewardType,offer.rewardPercent,offer.rewardAmount,offer.complimentaryItemId,offer.complimentaryItemName,now,offer.rewardMode ]); return {ok:true,offerName:offer.offerName,customerName:customer.name,customerPhone:phone,grantedAt:posDateText_(now)}; } finally { lock.releaseLock(); } } function getItemSalesTrends_(payload) { assertOwner_(); payload = payload || {}; const from = financeDateKey_(payload.from); const to = financeDateKey_(payload.to); if (from && to && from > to) throw new Error('From date must be on or before To date.'); const menu = readMenuItems_(getSettings_()); const byId = new Map(); menu.forEach(item => byId.set(String(item.id), { id:String(item.id),name:String(item.name || ''),price:Number(item.price)||0, quantitySold:0,orderCount:0,salesValue:0,_orders:new Set() })); const rows = readPosSheetObjects_(ensurePosSheets_().orders, POS_APP.ORDER_HEADERS); rows.forEach(order => { if (String(order.status || '').toUpperCase() !== 'SETTLED') return; const day = financeDateKey_(order.settledAt || order.createdAt); if (!day || (from && day < from) || (to && day > to)) return; let items = []; try { items = JSON.parse(String(order.itemsJson || '[]')); } catch(e) { items = []; } items.forEach(item => { const id = String(item.id || item.name || ''); if (!id) return; let current = byId.get(id); if (!current) { current = {id:id,name:String(item.name || 'Item'),price:Number(item.unitPrice)||0,quantitySold:0,orderCount:0,salesValue:0,_orders:new Set()}; byId.set(id,current); } if (!current.name) current.name = String(item.name || 'Item'); current.quantitySold += Number(item.quantity) || 0; current.salesValue = roundMoney_(current.salesValue + (Number(item.lineTotal) || (Number(item.unitPrice)||0)*(Number(item.quantity)||0))); current._orders.add(String(order.orderId)); }); }); const sort = String(payload.sort || 'TOP').toUpperCase(); const items = Array.from(byId.values()).map(item => ({ id:item.id,name:item.name,price:roundMoney_(item.price),quantitySold:item.quantitySold, orderCount:item._orders.size,salesValue:roundMoney_(item.salesValue) })).sort((a,b)=>sort === 'LOW' ? (a.quantitySold-b.quantitySold || a.name.localeCompare(b.name)) : (b.quantitySold-a.quantitySold || b.salesValue-a.salesValue || a.name.localeCompare(b.name))); return {from:from,to:to,items:items,settledOrderCount:rows.filter(row=>String(row.status||'').toUpperCase()==='SETTLED'&&(!from||financeDateKey_(row.settledAt||row.createdAt)>=from)&&(!to||financeDateKey_(row.settledAt||row.createdAt)<=to)).length}; } // ========================================================= // EXPENSES, STAFF, ATTENDANCE AND PAYROLL // All finance data is stored in private tabs in the config spreadsheet. // ========================================================= const FINANCE_APP = { EXPENSES_SHEET: 'Expenses', STAFF_SHEET: 'Staff', ATTENDANCE_SHEET: 'StaffAttendance', PAYROLL_SHEET: 'StaffPayroll', EXPENSE_HEADERS: [ 'expenseId', 'expenseDate', 'itemName', 'category', 'amount', 'description', 'invoiceFileName', 'invoiceMimeType', 'invoiceFileUrl', 'createdAt' ], STAFF_HEADERS: [ 'staffId', 'name', 'phone', 'aadhaarLast4', 'monthlySalary', 'paidLeaveDays', 'encashUnusedLeave', 'active', 'createdAt' ], ATTENDANCE_HEADERS: [ 'attendanceId', 'staffId', 'attendanceDate', 'status', 'notes', 'createdAt', 'updatedAt' ], PAYROLL_HEADERS: [ 'payrollId', 'staffId', 'staffName', 'payrollMonth', 'presentDays', 'absentDays', 'halfDays', 'paidLeaveDays', 'deduction', 'leavePay', 'netSalary', 'status', 'paidAt', 'createdAt' ] }; function ensureFinanceSheets_() { const ss = getConfigSpreadsheet_(); return { expenses: ensureSheetWithHeaders_(ss, FINANCE_APP.EXPENSES_SHEET, FINANCE_APP.EXPENSE_HEADERS), staff: ensureSheetWithHeaders_(ss, FINANCE_APP.STAFF_SHEET, FINANCE_APP.STAFF_HEADERS), attendance: ensureSheetWithHeaders_(ss, FINANCE_APP.ATTENDANCE_SHEET, FINANCE_APP.ATTENDANCE_HEADERS), payroll: ensureSheetWithHeaders_(ss, FINANCE_APP.PAYROLL_SHEET, FINANCE_APP.PAYROLL_HEADERS) }; } function financeDateKey_(value) { if (value instanceof Date && !isNaN(value.getTime())) { return Utilities.formatDate(value, Session.getScriptTimeZone(), 'yyyy-MM-dd'); } const text = String(value == null ? '' : value).trim(); if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text; const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const match = text.match(/^(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})/); if (match) { const month = monthNames.findIndex(name => name.toLowerCase() === match[2].toLowerCase()); if (month >= 0) return match[3] + '-' + String(month + 1).padStart(2, '0') + '-' + String(Number(match[1])).padStart(2, '0'); } const parsed = new Date(text); if (!isNaN(parsed.getTime())) return Utilities.formatDate(parsed, Session.getScriptTimeZone(), 'yyyy-MM-dd'); return ''; } function financeDateTimeText_(value) { if (value instanceof Date && !isNaN(value.getTime())) { return Utilities.formatDate(value, Session.getScriptTimeZone(), 'dd MMM yyyy, hh:mm a'); } return String(value || ''); } function readFinanceObjects_(sheet, headersExpected) { const values = sheet.getDataRange().getValues(); if (values.length < 2) return []; const headers = headerMapFromRow_(values[0]); return values.slice(1).map(row => { const obj = {}; headersExpected.forEach(key => { const column = headers[String(key).replace(/\s+/g, '').toLowerCase()]; obj[key] = column === undefined ? '' : row[column]; }); return obj; }).filter(obj => headersExpected.some(key => obj[key] !== '' && obj[key] != null)); } function readFinanceData_() { const sheets = ensureFinanceSheets_(); const expenses = readFinanceObjects_(sheets.expenses, FINANCE_APP.EXPENSE_HEADERS).map(row => ({ expenseId: String(row.expenseId || ''), expenseDate: financeDateKey_(row.expenseDate), itemName: String(row.itemName || ''), category: String(row.category || 'Other'), amount: roundMoney_(Number(row.amount) || 0), description: String(row.description || ''), invoiceFileName: String(row.invoiceFileName || ''), invoiceMimeType: String(row.invoiceMimeType || ''), invoiceFileUrl: String(row.invoiceFileUrl || ''), createdAt: financeDateTimeText_(row.createdAt) })).sort((a, b) => b.expenseDate.localeCompare(a.expenseDate)); const staff = readFinanceObjects_(sheets.staff, FINANCE_APP.STAFF_HEADERS) .filter(row => String(row.active).toLowerCase() !== 'false') .map(row => ({ staffId: String(row.staffId || ''), name: String(row.name || ''), phone: normalizeIndianCustomerPhone_(row.phone), aadhaarLast4: String(row.aadhaarLast4 || ''), monthlySalary: roundMoney_(Number(row.monthlySalary) || 0), paidLeaveDays: Number(row.paidLeaveDays) || 0, encashUnusedLeave: row.encashUnusedLeave === true || String(row.encashUnusedLeave).toLowerCase() === 'true', active: true })); const attendance = readFinanceObjects_(sheets.attendance, FINANCE_APP.ATTENDANCE_HEADERS).map(row => ({ attendanceId: String(row.attendanceId || ''), staffId: String(row.staffId || ''), attendanceDate: financeDateKey_(row.attendanceDate), status: String(row.status || 'PRESENT').toUpperCase(), notes: String(row.notes || ''), createdAt: financeDateTimeText_(row.createdAt), updatedAt: financeDateTimeText_(row.updatedAt) })); const payroll = readFinanceObjects_(sheets.payroll, FINANCE_APP.PAYROLL_HEADERS).map(row => ({ payrollId: String(row.payrollId || ''), staffId: String(row.staffId || ''), staffName: String(row.staffName || ''), payrollMonth: String(row.payrollMonth || ''), presentDays: Number(row.presentDays) || 0, absentDays: Number(row.absentDays) || 0, halfDays: Number(row.halfDays) || 0, paidLeaveDays: Number(row.paidLeaveDays) || 0, deduction: roundMoney_(Number(row.deduction) || 0), leavePay: roundMoney_(Number(row.leavePay) || 0), netSalary: roundMoney_(Number(row.netSalary) || 0), status: String(row.status || 'WAITING').toUpperCase(), paidAt: financeDateTimeText_(row.paidAt), createdAt: financeDateTimeText_(row.createdAt) })); return { expenses: expenses, staff: staff, attendance: attendance, payroll: payroll }; } function saveExpense_(payload) { assertOwner_(); payload = payload || {}; const expenseDate = financeDateKey_(payload.expenseDate); const itemName = cleanText_(payload.itemName, 120); const category = cleanText_(payload.category || 'Other', 40); const amount = Number(payload.amount); const description = cleanText_(payload.description, 500); const allowedCategories = ['Goods', 'Rent', 'Utilities', 'Maintenance', 'Transport', 'Marketing', 'Other']; if (!expenseDate) throw new Error('Select a valid expense date.'); if (!itemName) throw new Error('Enter the item or expense purpose.'); if (!allowedCategories.includes(category)) throw new Error('Select a valid expense category.'); if (!Number.isFinite(amount) || amount <= 0 || amount > 100000000) throw new Error('Enter a valid expense amount.'); let invoiceFileName = ''; let invoiceMimeType = ''; let invoiceFileUrl = ''; if (payload.invoice) { const invoice = payload.invoice; invoiceFileName = cleanText_(invoice.name || 'Invoice', 180); invoiceMimeType = String(invoice.mimeType || '').toLowerCase(); const allowedMimeTypes = ['application/pdf', 'image/jpeg', 'image/png']; if (!allowedMimeTypes.includes(invoiceMimeType)) throw new Error('Only PDF, JPG or PNG invoices are allowed.'); const dataUrl = String(invoice.dataUrl || ''); const base64 = dataUrl.includes(',') ? dataUrl.split(',').pop() : dataUrl; if (!base64) throw new Error('Invoice file data is missing.'); const bytes = Utilities.base64Decode(base64); if (bytes.length > 3 * 1024 * 1024) throw new Error('Invoice file must be 3 MB or smaller.'); const blob = Utilities.newBlob(bytes, invoiceMimeType, invoiceFileName); const file = DriveApp.createFile(blob); // Owner-only by default; link is not made public. invoiceFileUrl = file.getUrl(); } const sheets = ensureFinanceSheets_(); const now = new Date(); sheets.expenses.appendRow([ Utilities.getUuid(), expenseDate, itemName, category, roundMoney_(amount), description, invoiceFileName, invoiceMimeType, invoiceFileUrl, now ]); SpreadsheetApp.flush(); return { ok: true }; } function saveStaff_(payload) { assertOwner_(); payload = payload || {}; const name = cleanText_(payload.name, 100); const phoneInput = String(payload.phone || '').trim(); const phone = normalizeIndianCustomerPhone_(phoneInput); const aadhaarLast4 = String(payload.aadhaarLast4 || '').replace(/\D/g, '').slice(-4); const monthlySalary = Number(payload.monthlySalary); const paidLeaveDays = Number(payload.paidLeaveDays == null ? 4 : payload.paidLeaveDays); const encashUnusedLeave = payload.encashUnusedLeave === true || String(payload.encashUnusedLeave).toLowerCase() === 'true'; if (!name) throw new Error('Staff name is required.'); if (!phone) throw new Error('Enter a valid 10-digit Indian mobile number.'); if (!Number.isFinite(monthlySalary) || monthlySalary <= 0 || monthlySalary > 10000000) throw new Error('Enter a valid monthly salary.'); if (!Number.isFinite(paidLeaveDays) || paidLeaveDays < 0 || paidLeaveDays > 15) throw new Error('Allowed leave days must be between 0 and 15.'); if (payload.aadhaarLast4 && !/^\d{4}$/.test(aadhaarLast4)) throw new Error('Enter only the last 4 Aadhaar digits.'); const sheets = ensureFinanceSheets_(); const existingStaff = readFinanceObjects_(sheets.staff, FINANCE_APP.STAFF_HEADERS); if (existingStaff.some(row => normalizeIndianCustomerPhone_(row.phone) === phone && String(row.active).toLowerCase() !== 'false')) { throw new Error('A staff member with this mobile number already exists.'); } const now = new Date(); const staffId = Utilities.getUuid(); sheets.staff.appendRow([ staffId, name, phone, aadhaarLast4, roundMoney_(monthlySalary), paidLeaveDays, encashUnusedLeave, true, now ]); SpreadsheetApp.flush(); return { ok: true, staffId: staffId }; } function saveStaffAttendance_(payload) { assertOwner_(); payload = payload || {}; const date = financeDateKey_(payload.date); const today = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd'); if (!date) throw new Error('Select a valid attendance date.'); if (date > today) throw new Error('Future attendance cannot be saved.'); if (!Array.isArray(payload.entries) || !payload.entries.length) throw new Error('No attendance entries were provided.'); const lock = LockService.getScriptLock(); lock.waitLock(10000); try { const sheets = ensureFinanceSheets_(); const staffIds = new Set(readFinanceObjects_(sheets.staff, FINANCE_APP.STAFF_HEADERS) .filter(row => String(row.active).toLowerCase() !== 'false').map(row => String(row.staffId))); const values = sheets.attendance.getDataRange().getValues(); const headers = headerMapFromRow_(values[0]); const rows = values.slice(1); const now = new Date(); payload.entries.forEach(entry => { const staffId = cleanText_(entry.staffId, 100); const status = String(entry.status || '').toUpperCase(); const notes = cleanText_(entry.notes, 250); if (!staffIds.has(staffId)) throw new Error('A selected staff member was not found.'); if (!['PRESENT', 'ABSENT', 'HALF_DAY'].includes(status)) throw new Error('Invalid attendance status.'); const matchIndex = rows.findIndex(row => String(row[headers.staffid] || '') === staffId && financeDateKey_(row[headers.attendancedate]) === date); if (matchIndex >= 0) { const rowNumber = matchIndex + 2; const row = rows[matchIndex]; row[headers.status] = status; row[headers.notes] = notes; row[headers.updatedat] = now; sheets.attendance.getRange(rowNumber, 1, 1, row.length).setValues([row]); } else { sheets.attendance.appendRow([Utilities.getUuid(), staffId, date, status, notes, now, now]); } }); SpreadsheetApp.flush(); return { ok: true, date: date }; } finally { lock.releaseLock(); } } function calculateStaffPayrollServer_(staff, month, attendanceRows) { const parts = month.split('-').map(Number); if (parts.length !== 2 || !parts[0] || parts[1] < 1 || parts[1] > 12) throw new Error('Invalid payroll month.'); const days = new Date(parts[0], parts[1], 0).getDate(); const rows = attendanceRows.filter(row => String(row.staffId) === String(staff.staffId) && financeDateKey_(row.attendanceDate).startsWith(month + '-')); const present = rows.filter(row => String(row.status).toUpperCase() === 'PRESENT').length; const absent = rows.filter(row => String(row.status).toUpperCase() === 'ABSENT').length; const half = rows.filter(row => String(row.status).toUpperCase() === 'HALF_DAY').length; const absenceUnits = absent + half * 0.5; const allowance = Number(staff.paidLeaveDays) || 0; const daily = (Number(staff.monthlySalary) || 0) / days; const deduction = roundMoney_(Math.max(0, absenceUnits - allowance) * daily); const leavePay = staff.encashUnusedLeave ? roundMoney_(Math.max(0, allowance - absenceUnits) * daily) : 0; const netSalary = Math.max(0, roundMoney_((Number(staff.monthlySalary) || 0) - deduction + leavePay)); return { present: present, absent: absent, half: half, allowance: allowance, deduction: deduction, leavePay: leavePay, netSalary: netSalary }; } function saveStaffPayroll_(payload) { assertOwner_(); payload = payload || {}; const staffId = cleanText_(payload.staffId, 100); const month = String(payload.month || '').trim(); if (!staffId || !/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw new Error('Select a valid staff member and payroll month.'); const parts = month.split('-').map(Number); const nextMonthStart = new Date(parts[0], parts[1], 1); const today = new Date(); today.setHours(0, 0, 0, 0); if (today < nextMonthStart) throw new Error("Can't mark paid before the month end."); const lock = LockService.getScriptLock(); lock.waitLock(10000); try { const sheets = ensureFinanceSheets_(); const staff = readFinanceObjects_(sheets.staff, FINANCE_APP.STAFF_HEADERS) .map(row => ({ staffId: String(row.staffId || ''), name: String(row.name || ''), monthlySalary: Number(row.monthlySalary) || 0, paidLeaveDays: Number(row.paidLeaveDays) || 0, encashUnusedLeave: row.encashUnusedLeave === true || String(row.encashUnusedLeave).toLowerCase() === 'true' })) .find(row => row.staffId === staffId); if (!staff) throw new Error('Staff member was not found.'); const payrollRows = readFinanceObjects_(sheets.payroll, FINANCE_APP.PAYROLL_HEADERS); if (payrollRows.some(row => String(row.staffId) === staffId && String(row.payrollMonth) === month && String(row.status).toUpperCase() === 'PAID')) { throw new Error('This staff salary is already marked as paid.'); } const attendance = readFinanceObjects_(sheets.attendance, FINANCE_APP.ATTENDANCE_HEADERS); const calc = calculateStaffPayrollServer_(staff, month, attendance); const now = new Date(); sheets.payroll.appendRow([ Utilities.getUuid(), staff.staffId, staff.name, month, calc.present, calc.absent, calc.half, calc.allowance, calc.deduction, calc.leavePay, calc.netSalary, 'PAID', now, now ]); SpreadsheetApp.flush(); return { ok: true, staffId: staffId, month: month, netSalary: calc.netSalary, status: 'PAID' }; } finally { lock.releaseLock(); } }