Add services to see receipt.
No transactions yet.
Count the bills in the till
Amount to pull from till after tip payout and closing float
Edit names, prices and categories. Changes persist across sessions.
| # | Service Name | Price ($) | Category |
|---|
Configure the Google Sheets client database connection.
The token is your private key — paste the same value into the Apps Script below. Requests without it are rejected.
YOUR_SECRET_TOKEN_HERE with your token// ============================================================
// ELLE NAIL BAR — Google Apps Script
// Handles: Client lookup, Transaction saving, Payroll sheet
//
// SETUP:
// 1. Paste this entire file into your Google Apps Script editor
// 2. Change SECRET_TOKEN below to match what you set in POS Settings
// 3. Deploy as Web App: Execute as Me, Anyone can access
// 4. Copy the Web App URL into POS Settings
// ============================================================
// ── CHANGE THIS TO MATCH YOUR POS SETTINGS TOKEN ─────────────
var SECRET_TOKEN = 'YOUR_SECRET_TOKEN_HERE';
// ── SHEET NAMES ───────────────────────────────────────────────
var SHEET_CLIENTS = 'Clients';
var SHEET_TRANSACTIONS = 'Transactions';
var SHEET_PAYROLL = 'Payroll';
// ── STAFF CONFIG ─────────────────────────────────────────────
// Format: 'Name': { commission: 0.XX, guarantee: XX (per day, 0 = none) }
var STAFF_CONFIG = {
'Cherry': { commission: 0.60, guarantee: 250 },
'Quynh (Amy)': { commission: 0.55, guarantee: 100 },
'Irene': { commission: 0.60, guarantee: 200 },
'Van (Vina)': { commission: 0.55, guarantee: 100 },
'Chloe': { commission: 0.55, guarantee: 100 },
'CECE': { commission: 0.60, guarantee: 200 },
'Kate (Chi)': { commission: 0.50, guarantee: 50 },
'Zen': { commission: 0.50, guarantee: 100 },
};
var CREDIT_TIP_DEDUCTION = 0.16; // 16% deducted from credit tips before payout
// ============================================================
// MAIN ROUTER
// ============================================================
function doGet(e) {
if (!e.parameter.token || e.parameter.token !== SECRET_TOKEN) {
return json({ ok: false, error: 'Unauthorized' });
}
var action = e.parameter.action || 'search';
if (action === 'search') return handleSearch(e);
return json({ ok: false, error: 'Unknown action' });
}
function doPost(e) {
var params = JSON.parse(e.postData.contents);
if (!params.token || params.token !== SECRET_TOKEN) {
return json({ ok: false, error: 'Unauthorized' });
}
var action = getParam(e, 'action') || params.action || 'saveTx';
if (action === 'saveTx') return handleSaveTx(params);
if (action === 'deleteTx') return handleDeleteTx(params);
return json({ ok: false, error: 'Unknown action' });
}
// ============================================================
// CLIENT SEARCH
// ============================================================
function handleSearch(e) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName(SHEET_CLIENTS);
if (!sheet) return json({ ok: false, error: 'Clients sheet not found. Make sure you have a tab named "Clients".' });
var data = sheet.getDataRange().getValues();
var headers = data[0].map(function(h){ return h.toString().trim(); });
var firstIdx = findCol(headers, /first.?name/i);
var lastIdx = findCol(headers, /last.?name/i);
var phoneIdx = findCol(headers, /mobile|phone/i);
var emailIdx = findCol(headers, /email/i);
var q = (e.parameter.q || '').toLowerCase().trim();
var results = data.slice(1).map(function(row) {
return {
firstName: firstIdx >= 0 ? row[firstIdx].toString().trim() : '',
lastName: lastIdx >= 0 ? row[lastIdx].toString().trim() : '',
phone: phoneIdx >= 0 ? row[phoneIdx].toString().trim() : '',
email: emailIdx >= 0 ? row[emailIdx].toString().trim() : ''
};
})
.filter(function(c){ return c.firstName || c.lastName; })
.filter(function(c){
if (!q) return true;
var full = (c.firstName + ' ' + c.lastName).toLowerCase();
var phoneClean = c.phone.replace(/\D/g, '');
var qClean = q.replace(/\D/g, '');
return full.includes(q) ||
(qClean.length > 3 && phoneClean.includes(qClean)) ||
c.email.toLowerCase().includes(q);
})
.slice(0, 20);
return json({ ok: true, results: results });
}
// ============================================================
// SAVE TRANSACTION
// ============================================================
function handleSaveTx(params) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName(SHEET_TRANSACTIONS);
// Create Transactions sheet if it doesn't exist
if (!sheet) {
sheet = ss.insertSheet(SHEET_TRANSACTIONS);
var headers = [
'Transaction ID','Date','Time','Client','Service','Tech',
'Service Price ($)','Discount ($)','Discount Type',
'Cash Discount ($)','HST ($)','Transaction Total ($)',
'Payment Method','Total Tip ($)','Tip Type',
'Tech Tip ($)','Tech Tip Type','Notes'
];
sheet.appendRow(headers);
formatHeaderRow(sheet, headers.length);
}
var rows = params.rows || [];
rows.forEach(function(r) {
sheet.appendRow([
r.txId || '',
r.date || '',
r.time || '',
r.client || '',
r.service || '',
r.tech || '',
r.servicePrice|| 0,
r.discountAmt || 0,
r.discountType|| '',
r.cashDisc || 0,
r.hst || 0,
r.total || 0,
r.payment || '',
r.totalTip || 0,
r.tipType || '',
r.techTip || 0,
r.techTipType || '',
r.notes || ''
]);
});
// Rebuild payroll after every save
buildPayrollSheet(ss);
return json({ ok: true, rowsSaved: rows.length });
}
// ============================================================
// DELETE TRANSACTION
// ============================================================
function handleDeleteTx(params) {
var txId = String(params.txId || '');
if (!txId) return json({ ok: false, error: 'No txId provided' });
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName(SHEET_TRANSACTIONS);
if (!sheet) return json({ ok: false, error: 'Transactions sheet not found' });
var data = sheet.getDataRange().getValues();
var txIdColIdx = data[0].findIndex(function(h){ return h.toString().trim() === 'Transaction ID'; });
if (txIdColIdx < 0) return json({ ok: false, error: 'Transaction ID column not found' });
// Collect row numbers to delete (reverse order so indices don't shift)
var rowsToDelete = [];
data.forEach(function(row, i) {
if (i === 0) return; // skip header
if (String(row[txIdColIdx]) === txId) rowsToDelete.push(i + 1); // +1 for 1-based sheet index
});
if (rowsToDelete.length === 0) return json({ ok: false, error: 'Transaction not found in sheet' });
// Delete rows in reverse order so row numbers stay valid
rowsToDelete.reverse().forEach(function(rowNum) {
sheet.deleteRow(rowNum);
});
// Rebuild payroll after deletion
buildPayrollSheet(ss);
return json({ ok: true, rowsDeleted: rowsToDelete.length });
}
// ============================================================
// PAYROLL SHEET BUILDER
// ============================================================
function buildPayrollSheet(ss) {
var txSheet = ss.getSheetByName(SHEET_TRANSACTIONS);
if (!txSheet) return;
var data = txSheet.getDataRange().getValues();
if (data.length < 2) return;
var headers = data[0];
var col = {};
headers.forEach(function(h, i){ col[h.toString().trim()] = i; });
// Group rows by period (1-15 or 16-end) and tech
var periods = {};
data.slice(1).forEach(function(row) {
var dateStr = row[col['Date']] ? row[col['Date']].toString() : '';
if (!dateStr) return;
var date = new Date(dateStr);
var year = date.getFullYear();
var month = date.getMonth() + 1;
var day = date.getDate();
var half = day <= 15 ? 1 : 2;
var monthStr= year + '-' + pad(month);
var periodKey = monthStr + (half === 1 ? ' (1-15)' : ' (16-end)');
var tech = (row[col['Tech']] || '').toString().trim();
var svcPrice = parseFloat(row[col['Service Price ($)']]) || 0;
var techTip = parseFloat(row[col['Tech Tip ($)']]) || 0;
var techTipType= (row[col['Tech Tip Type']] || '').toString().trim().toLowerCase();
if (!tech) return;
if (!periods[periodKey]) periods[periodKey] = {};
if (!periods[periodKey][tech]) {
periods[periodKey][tech] = {
days: {},
totalSales: 0,
cashTips: 0,
creditTips: 0
};
}
var entry = periods[periodKey][tech];
entry.totalSales += svcPrice;
if (techTipType === 'cash') entry.cashTips += techTip;
if (techTipType === 'card') entry.creditTips += techTip;
// Track days worked (unique dates with sales)
if (svcPrice > 0 && dateStr) entry.days[dateStr] = true;
});
// Write payroll sheet
var pSheet = ss.getSheetByName(SHEET_PAYROLL);
if (pSheet) ss.deleteSheet(pSheet);
pSheet = ss.insertSheet(SHEET_PAYROLL);
var allRows = [];
var periodKeys = Object.keys(periods).sort();
periodKeys.forEach(function(period) {
// Period header
allRows.push([period, '', '', '', '', '', '', '', '', '', '', '', '']);
allRows.push([
'Staff','Commission %','Daily Guarantee',
'Days Worked','Total Sales ($)','Commission Pay ($)',
'Guaranteed Pay ($)','Pre-tip Pay ($)',
'Cash Tips ($)','Credit Tips (gross $)',
'Credit Tips (after 16%) ($)','Total Cash Payout ($)',
'Notes'
]);
var techs = Object.keys(periods[period]).sort();
techs.forEach(function(tech) {
var d = periods[period][tech];
var cfg = STAFF_CONFIG[tech] || { commission: 0.55, guarantee: 0 };
var daysWorked = Object.keys(d.days).length;
var commissionPay = +(d.totalSales * cfg.commission).toFixed(2);
var guaranteedPay = cfg.guarantee > 0 ? +(cfg.guarantee * daysWorked).toFixed(2) : 0;
var preTipPay = Math.max(commissionPay, guaranteedPay);
var creditTipsNet = +(d.creditTips * (1 - CREDIT_TIP_DEDUCTION)).toFixed(2);
var totalCash = +(preTipPay + d.cashTips + creditTipsNet).toFixed(2);
allRows.push([
tech,
(cfg.commission * 100).toFixed(0) + '%',
cfg.guarantee > 0 ? '$' + cfg.guarantee + '/day' : 'None',
daysWorked,
d.totalSales.toFixed(2),
commissionPay.toFixed(2),
guaranteedPay > 0 ? guaranteedPay.toFixed(2) : 'N/A',
preTipPay.toFixed(2),
d.cashTips.toFixed(2),
d.creditTips.toFixed(2),
creditTipsNet.toFixed(2),
totalCash.toFixed(2),
commissionPay >= guaranteedPay ? 'Commission' : 'Guaranteed'
]);
});
// Period totals
var totals = { sales:0, commPay:0, guarPay:0, preTip:0, cashTips:0, creditGross:0, creditNet:0, totalCash:0 };
techs.forEach(function(tech){
var d = periods[period][tech];
var cfg = STAFF_CONFIG[tech] || { commission:0.55, guarantee:0 };
var dw = Object.keys(d.days).length;
var cp = d.totalSales * cfg.commission;
var gp = cfg.guarantee > 0 ? cfg.guarantee * dw : 0;
var ptp = Math.max(cp, gp);
var ctn = d.creditTips * (1 - CREDIT_TIP_DEDUCTION);
totals.sales += d.totalSales;
totals.commPay += cp;
totals.guarPay += gp;
totals.preTip += ptp;
totals.cashTips += d.cashTips;
totals.creditGross += d.creditTips;
totals.creditNet += ctn;
totals.totalCash += ptp + d.cashTips + ctn;
});
allRows.push([
'TOTAL','','','',
totals.sales.toFixed(2),
totals.commPay.toFixed(2),
totals.guarPay.toFixed(2),
totals.preTip.toFixed(2),
totals.cashTips.toFixed(2),
totals.creditGross.toFixed(2),
totals.creditNet.toFixed(2),
totals.totalCash.toFixed(2),
''
]);
allRows.push(['', '', '', '', '', '', '', '', '', '', '', '', '']); // spacer
});
if (allRows.length === 0) {
pSheet.appendRow(['No transaction data yet.']);
return;
}
// Write all rows at once (faster than one at a time)
pSheet.getRange(1, 1, allRows.length, 13).setValues(allRows);
// Format the sheet
pSheet.setColumnWidths(1, 13, 130);
pSheet.setColumnWidth(1, 160);
// Style period headers and column headers
var rowNum = 1;
periodKeys.forEach(function(period) {
// Period header row
pSheet.getRange(rowNum, 1, 1, 13)
.setBackground('#2C1810')
.setFontColor('#FAF7F2')
.setFontWeight('bold')
.setFontSize(11);
rowNum++;
// Column header row
pSheet.getRange(rowNum, 1, 1, 13)
.setBackground('#C9786A')
.setFontColor('#FFFFFF')
.setFontWeight('bold')
.setFontSize(10);
rowNum++;
var techCount = Object.keys(periods[period]).length;
// Alternate row shading for staff rows
for (var i = 0; i < techCount; i++) {
var bg = i % 2 === 0 ? '#FFF9F4' : '#FAF7F2';
pSheet.getRange(rowNum, 1, 1, 13).setBackground(bg);
rowNum++;
}
// Totals row
pSheet.getRange(rowNum, 1, 1, 13)
.setBackground('#8B3D31')
.setFontColor('#FFFFFF')
.setFontWeight('bold');
rowNum++;
rowNum++; // spacer
});
// Freeze top row equivalent - freeze column headers of first period
pSheet.setFrozenRows(2);
SpreadsheetApp.flush();
}
// ============================================================
// UTILITY FUNCTIONS
// ============================================================
function json(obj) {
return ContentService
.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
function findCol(headers, pattern) {
return headers.findIndex(function(h){ return pattern.test(h); });
}
function getParam(e, key) {
return e && e.parameter ? e.parameter[key] : null;
}
function pad(n) {
return n < 10 ? '0' + n : '' + n;
}
function formatHeaderRow(sheet, numCols) {
var cols = numCols || 18;
sheet.getRange(1, 1, 1, cols)
.setBackground('#2C1810')
.setFontColor('#FAF7F2')
.setFontWeight('bold')
.setFontSize(10);
sheet.setFrozenRows(1);
sheet.setColumnWidths(1, cols, 120);
sheet.setColumnWidth(1, 160); // Transaction ID
sheet.setColumnWidth(4, 150); // Client
sheet.setColumnWidth(5, 200); // Service
sheet.setColumnWidth(6, 130); // Tech
}
// ============================================================
// MANUAL TRIGGER — run this once to set up sheets
// ============================================================
function setupSheets() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
// Clients sheet
if (!ss.getSheetByName(SHEET_CLIENTS)) {
var cs = ss.insertSheet(SHEET_CLIENTS);
cs.appendRow(['First Name','Last Name','Full Name','Blocked','Mobile Number','Email']);
cs.getRange(1,1,1,6).setBackground('#2C1810').setFontColor('#FAF7F2').setFontWeight('bold');
cs.setFrozenRows(1);
}
// Transactions sheet
if (!ss.getSheetByName(SHEET_TRANSACTIONS)) {
var ts = ss.insertSheet(SHEET_TRANSACTIONS);
var txHeaders = [
'Transaction ID','Date','Time','Client','Service','Tech',
'Service Price ($)','Discount ($)','Discount Type',
'Cash Discount ($)','HST ($)','Transaction Total ($)',
'Payment Method','Total Tip ($)','Tip Type',
'Tech Tip ($)','Tech Tip Type','Notes'
];
ts.appendRow(txHeaders);
formatHeaderRow(ts, txHeaders.length);
}
// Payroll sheet placeholder
if (!ss.getSheetByName(SHEET_PAYROLL)) {
var ps = ss.insertSheet(SHEET_PAYROLL);
ps.appendRow(['Payroll data will appear here automatically after transactions are saved.']);
}
SpreadsheetApp.getUi().alert('✓ Elle Nail Bar sheets are set up and ready!');
}
The 4-digit PIN staff enter to open the POS. Default is 1234 — change it now.
Wipe all transactions from this device. Use this to remove demo/test data before going live. This cannot be undone.
Requires current admin password to confirm.