HOW TO BUILD POS CASHIER USING APPS SCRIPT
Learn how to build a practical POS cashier using Google Apps Script and Sheets, with products, cart checkout, stock updates, receipts, and sales history.
Learning how to build POS cashier using Apps Script is a practical way to create a small-shop checkout system without a separate server. In this guide, you will build a browser-based cashier backed by Google Sheets. The minimum viable point-of-sale app will load products, add items to a cart, calculate totals, accept a payment amount, record the sale, reduce inventory, and display a receipt number.
This design is suitable for a small store, pop-up business, school office, or internal counter. It is not a replacement for a regulated retail or accounting platform, so the limitations around security, concurrency, printing, and scale matter.
What We Are Building
We will create a Google Apps Script web app with a simple cashier screen. A cashier selects products, enters quantities, reviews the subtotal, and submits the transaction. Apps Script validates the request and writes the sale to Google Sheets.
The finished application has three layers:
- Frontend: an HTML Service page named
index.htmlcontaining the product list, cart, totals, and checkout controls. - Backend: a server-side file named
Code.gsthat reads products, validates sales, updates stock, and creates receipt numbers. - Database: a spreadsheet containing
Products,Sales,SaleItems, andSettingssheets.
The example assumes one store, one currency, and a small number of simultaneous cashiers. It records cash payments and calculates change; card or digital payments can be added as a payment method without changing the basic data model.
Features
- Load active products with SKU, name, price, and available stock.
- Search products by SKU or product name.
- Add products to a cart and change quantities.
- Calculate subtotal, tax, amount paid, and change in the browser.
- Validate stock and payment values on the server before saving.
- Write one sale header and multiple sale-line records.
- Reduce inventory only after the sale passes validation.
- Return a receipt number and a printable receipt summary.
Keeping the first version focused is important. Customer accounts, refunds, barcode hardware, accounting synchronization, and multi-location stock are better treated as later extensions.
Architecture
Data flow
- The web page calls
getInitialData()throughgoogle.script.run. - Apps Script reads active rows from
Productsand returns plain objects to the browser. - The cashier builds a cart locally. The browser calculates a preview, but the server recalculates all financial values.
- When checkout is submitted,
saveSale()obtains a script lock, rereads current stock, validates the cart, and writes the transaction. - The server updates product stock and returns a receipt number. The browser clears the cart and shows the result.
The browser is not trusted with final prices or inventory decisions. A user could alter client-side JavaScript, so the backend must read current prices and stock from the spreadsheet again.
Sheet responsibilities
| Sheet | Purpose |
|---|---|
Products | Product catalog and current stock. |
Sales | One row per completed transaction. |
SaleItems | One row per product line in a transaction. |
Settings | Store name and tax rate. |
Visual suggestion: An architecture diagram showing index.html, google.script.run, Code.gs, and the four spreadsheet tabs would help readers understand the data movement.
Create the Google Sheet
Create a spreadsheet for the application, open Extensions → Apps Script, and add the sheets below. Keep the header spelling consistent because the backend uses these columns by name.
Products sheet
| Column | Meaning | Example |
|---|---|---|
| A: SKU | Unique product code | COF-001 |
| B: Name | Display name | Ground Coffee |
| C: Price | Unit selling price | 8.50 |
| D: Stock | Available quantity | 25 |
| E: Active | TRUE or FALSE | TRUE |
Enter the headers in row 1 and sample products from row 2 onward. Format Price as currency and Stock as a number. Use a unique SKU for every product; duplicate SKUs make stock updates ambiguous.
Sales and SaleItems sheets
Use these exact headers:
Sales:ReceiptNo,Timestamp,Subtotal,Tax,Total,PaymentMethod,AmountPaid,Change,CashierSaleItems:ReceiptNo,SKU,ProductName,Quantity,UnitPrice,LineTotal
In Settings, enter two columns: Key and Value. Add storeName with a value such as Corner Shop, and taxRate with a decimal such as 0.075 for 7.5 percent. If you do not charge tax, use 0.
Build the Apps Script Backend
Put the following code in Code.gs. It is designed for a script bound to the POS spreadsheet, so SpreadsheetApp.getActive() refers to the correct file after authorization.
const SHEETS = {
products: 'Products',
sales: 'Sales',
items: 'SaleItems',
settings: 'Settings'
};
function doGet() {
return HtmlService.createHtmlOutputFromFile('index')
.setTitle('POS Cashier');
}
function getInitialData() {
const ss = SpreadsheetApp.getActive();
const sheet = ss.getSheetByName(SHEETS.products);
if (!sheet) throw new Error('Products sheet was not found.');
const values = sheet.getDataRange().getValues();
const products = values.slice(1).filter(row => row[0] && row[4] !== false)
.map(row => ({
sku: String(row[0]),
name: String(row[1]),
price: Number(row[2]),
stock: Number(row[3])
}));
return { products, taxRate: getTaxRate() };
}
function getTaxRate() {
const sheet = SpreadsheetApp.getActive().getSheetByName(SHEETS.settings);
if (!sheet) return 0;
const rows = sheet.getDataRange().getValues();
const row = rows.slice(1).find(item => String(item[0]) === 'taxRate');
return row ? Number(row[1]) || 0 : 0;
}
function saveSale(request) {
if (!request || !Array.isArray(request.items) || request.items.length === 0) {
throw new Error('Add at least one product to the cart.');
}
const paymentMethod = String(request.paymentMethod || 'Cash');
const amountPaid = Number(request.amountPaid);
if (!Number.isFinite(amountPaid) || amountPaid < 0) {
throw new Error('Enter a valid payment amount.');
}
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const ss = SpreadsheetApp.getActive();
const productSheet = ss.getSheetByName(SHEETS.products);
const salesSheet = ss.getSheetByName(SHEETS.sales);
const itemSheet = ss.getSheetByName(SHEETS.items);
if (!productSheet || !salesSheet || !itemSheet) {
throw new Error('One or more required sheets are missing.');
}
const rows = productSheet.getDataRange().getValues();
const bySku = {};
rows.slice(1).forEach((row, index) => {
bySku[String(row[0])] = { rowNumber: index + 2, name: String(row[1]),
price: Number(row[2]), stock: Number(row[3]), active: row[4] !== false };
});
const checked = request.items.map(item => {
const sku = String(item.sku);
const product = bySku[sku];
const quantity = Number(item.quantity);
if (!product || !product.active) throw new Error('Product is unavailable: ' + sku);
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Invalid quantity for ' + sku);
if (quantity > product.stock) throw new Error('Not enough stock for ' + product.name);
return { sku, product, quantity, lineTotal: product.price * quantity };
});
const subtotal = checked.reduce((sum, item) => sum + item.lineTotal, 0);
const tax = subtotal * getTaxRate();
const total = subtotal + tax;
if (paymentMethod === 'Cash' && amountPaid < total) {
throw new Error('Cash received is less than the sale total.');
}
const change = paymentMethod === 'Cash' ? amountPaid - total : 0;
const receiptNo = 'R-' + Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyyMMdd-HHmmss') + '-' + Math.floor(Math.random() * 1000);
salesSheet.appendRow([receiptNo, new Date(), subtotal, tax, total, paymentMethod, amountPaid, change, Session.getActiveUser().getEmail()]);
const itemRows = checked.map(item => [receiptNo, item.sku, item.product.name, item.quantity, item.product.price, item.lineTotal]);
itemSheet.getRange(itemSheet.getLastRow() + 1, 1, itemRows.length, itemRows[0].length).setValues(itemRows);
checked.forEach(item => productSheet.getRange(item.product.rowNumber, 4).setValue(item.product.stock - item.quantity));
return { receiptNo, subtotal, tax, total, change };
} finally {
lock.releaseLock();
}
}
saveSale() intentionally rereads the product sheet while holding a script lock. That reduces the chance that two cashier requests sell the same final unit. It is still not a full inventory transaction system, and a failed write after a partial operation should be reviewed in the spreadsheet.
For larger catalogs, replace repeated setValue() calls with a single array write. The example favors readability, but batch operations are preferable when many products are updated at once.
Build the Frontend
Add an HTML file named index.html. The following compact example demonstrates the important client-server pattern. Add your preferred CSS around these elements; the IDs must remain consistent with the client code.
<input id="search" placeholder="Search SKU or product">
<div id="products"></div>
<h2>Cart</h2>
<div id="cart"></div>
<p>Total: <strong id="total">0.00</strong></p>
<select id="paymentMethod">
<option>Cash</option>
<option>Card</option>
<option>Other</option>
</select>
<input id="amountPaid" type="number" min="0" step="0.01" placeholder="Amount received">
<button id="checkout">Complete sale</button>
<div id="message" role="status"></div>
<script>
let products = [];
let cart = [];
let taxRate = 0;
function showMessage(text, isError) {
const message = document.getElementById('message');
message.textContent = text;
message.className = isError ? 'error' : 'success';
}
function loadData() {
google.script.run
.withSuccessHandler(data => {
products = data.products;
taxRate = data.taxRate;
renderProducts(products);
})
.withFailureHandler(error => showMessage(error.message, true))
.getInitialData();
}
function addToCart(sku) {
const product = products.find(item => item.sku === sku);
const line = cart.find(item => item.sku === sku);
if (!product) return;
if (line) line.quantity += 1;
else cart.push({ sku, quantity: 1 });
renderCart();
}
function checkout() {
const amountPaid = Number(document.getElementById('amountPaid').value || 0);
const paymentMethod = document.getElementById('paymentMethod').value;
document.getElementById('checkout').disabled = true;
google.script.run
.withSuccessHandler(result => {
showMessage('Sale saved: ' + result.receiptNo + '. Change: ' + result.change.toFixed(2));
cart = [];
renderCart();
loadData();
document.getElementById('checkout').disabled = false;
})
.withFailureHandler(error => {
showMessage(error.message, true);
document.getElementById('checkout').disabled = false;
})
.saveSale({ items: cart, amountPaid, paymentMethod });
}
loadData();
</script>
The omitted renderProducts() and renderCart() functions should create buttons that call addToCart(sku) and display the current cart. Keep product values in text nodes or escaped HTML rather than inserting untrusted names directly into raw HTML. In a complete interface, also disable checkout when the cart is empty and show a clear loading state before the first response.
Always use both withSuccessHandler() and withFailureHandler(). Without a failure handler, a server exception can look like a frozen button or an application that silently does nothing.
AI Prompt
Use this prompt to generate a customized version of the same project. Review the output rather than trusting generated code with real sales data.
Build a small Google Apps Script POS cashier web app backed by one Google Sheet.
Goal:
Create a cashier interface that loads active products, searches by SKU or name, adds products to a cart, calculates subtotal/tax/total, accepts Cash/Card/Other, validates the payment, saves a sale, reduces stock, and returns a receipt number.
File structure:
- Code.gs for doGet, configuration, reading products, server-side validation, locking, saving Sales and SaleItems, and error handling.
- index.html for the responsive cashier UI, CSS, client-side cart state, rendering, and google.script.run calls.
Sheet schema:
- Products: SKU, Name, Price, Stock, Active
- Sales: ReceiptNo, Timestamp, Subtotal, Tax, Total, PaymentMethod, AmountPaid, Change, Cashier
- SaleItems: ReceiptNo, SKU, ProductName, Quantity, UnitPrice, LineTotal
- Settings: Key, Value, including storeName and taxRate
Requirements:
1. Recalculate prices, totals, stock, and change on the server; never trust browser totals.
2. Validate SKU, integer quantity, active status, stock availability, payment method, and cash received.
3. Use a script lock during stock validation and writes.
4. Prefer batch reads and writes where practical.
5. Use google.script.run success and failure handlers and disable the checkout button during submission.
6. Escape product names safely in the frontend and avoid exposing secrets.
7. Explain authorization scopes, deployment settings, execute-as choices, and access permissions.
8. Include setup instructions, sample data, test cases, and troubleshooting for missing sheets, invalid IDs, authorization, stale deployments, and server errors.
9. Do not claim that the code was tested, and do not invent quotas or production performance.
10. Clearly state limitations around simultaneous users, refunds, audit controls, printing, and scale.
Return complete Code.gs and index.html files, then explain each important function and how to deploy the web app.
Deploy the Application
Save and authorize
- Save both
Code.gsandindex.html. - From the Apps Script editor, run
getInitialDataonce. Review the requested spreadsheet access and authorize the project with the account that owns or can edit the workbook. - Confirm that the spreadsheet has the four required tabs and that the product data is valid.
Create a web app deployment
- Select Deploy → New deployment.
- Choose Web app as the deployment type.
- Choose whether the app executes as you or as the accessing user. Executing as the owner is simpler for a controlled cashier team, but it means the app can use the owner's spreadsheet access. Restrict who can access the deployment accordingly.
- Create the deployment, authorize any additional prompt, and copy the web app URL.
- Open the URL in a private browser window or a permitted cashier account and complete a small test sale.
When you change server code, create a new deployment version or update the existing deployment as appropriate. Editing the project does not necessarily change the version already used by a deployed web app. Verify the deployed URL rather than assuming the editor preview represents production.
Verification checklist
- A product appears in the browser and can be added to the cart.
- A cash sale with sufficient payment creates one row in
Salesand the correct line rows inSaleItems. - The product stock decreases by the purchased quantity.
- An insufficient-payment test creates no sale.
- An out-of-stock test is rejected even if the browser still shows old stock.
- The deployment account can edit the target spreadsheet or the app executes under an account that can.
For a visual reference, an inventory and purchase order system template can help when the cashier also needs supplier records, purchase orders, and low-stock workflows.
Limitations

- Concurrency: a script lock helps serialize checkout requests, but it does not provide the same guarantees as a dedicated transactional database.
- Scale: spreadsheet reads, writes, Apps Script execution time, and service quotas are finite and change over time. Check Google's current Apps Script quota documentation before planning a busy retail operation.
- Security: a web app that executes as the owner must be protected with appropriate access settings. Do not publish it anonymously for real sales data unless you have designed and reviewed the security model.
- Audit controls: this MVP has no role-based permissions, immutable audit log, refund workflow, shift closing, or approval process.
- Payments: recording “Card” is not payment processing. It does not charge a card or verify a transaction with a payment provider.
- Printing and hardware: receipt printers, barcode scanners, cash drawers, and offline operation require browser, device, or third-party integration work.
- Data integrity: users who manually change product prices, SKUs, or stock in Sheets can affect future transactions. Protect the sheet and restrict editing where possible.
For multiple locations, frequent transactions, offline requirements, or formal accounting and tax obligations, consider a dedicated POS platform or a database-backed application with stronger authentication and transaction handling.
How to Improve It
- Add a product administration screen: allow authorized users to create products, deactivate old SKUs, and record stock adjustments instead of editing cells manually.
- Create a stock movement log: record purchases, sales, returns, and adjustments as an append-only ledger. Calculate stock from movements or reconcile the ledger against the current quantity.
- Add cashier identity and roles: use authenticated Workspace accounts and maintain a separate staff configuration sheet. Do not treat a name typed into the browser as authentication.
- Build a returns workflow: reference the original receipt, validate the quantity returned, restore stock, and write a separate reversal record rather than deleting the original sale.
- Improve performance: read the catalog once, cache data for short periods where appropriate, and replace per-row spreadsheet writes with batch operations.
- Add reporting: create daily sales summaries, payment-method totals, low-stock alerts, and end-of-shift reports using separate functions or pivot tables.
- Integrate a payment provider carefully: use a verified provider flow and store only the reference needed for reconciliation. Never store card numbers or security codes in Sheets.
Start with the stock movement log and access controls before adding cosmetic features. Those changes improve reliability and make the cashier system easier to audit as usage grows.
Common Questions
Can Apps Script process card payments?
Apps Script can record a payment method and can call an external payment API, but it is not itself a card processor. Use a provider's hosted checkout or approved integration and store a transaction reference rather than sensitive card data.
Can several cashiers use this POS at once?
They can use the same deployed web app, but simultaneous sales require careful locking, permissions, and testing. A spreadsheet-backed POS is best kept to modest workloads unless you have added stronger data and monitoring controls.
Why did stock not update after a sale?
Check the Apps Script execution history first. A missing sheet, invalid SKU, permission failure, or server exception may have stopped the operation. Then compare the receipt row, item rows, and product stock using the same receipt number and SKU.
How can I print a receipt?
Return the saved sale details to the frontend and provide a print-friendly receipt area using the browser's print command. Test the layout with the actual printer; Apps Script does not automatically control every receipt-printer model.
Conclusion
A Google Sheet and Apps Script can provide a useful small-business cashier when the workflow is simple and the data volume is manageable. The key design choice is to keep the browser convenient but make the server authoritative: validate current prices and stock, lock the checkout path, and preserve a clear sales history. Once that foundation works, add stock movements, roles, returns, and reporting before attempting deeper integrations.
About the author
Free Apps Script Team
This guide was created and reviewed for practical Google Workspace automation. Test scripts with sample data and review requested permissions before using them in production.