Google Apps Script TutorialGoogle Apps ScriptPOS SystemGoogle SheetsInventory Management

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.

Free Apps Script TeamAugust 28, 202613 min read
HOW TO BUILD POS CASHIER USING APPS SCRIPT guide cover illustration
HOW TO BUILD POS CASHIER USING APPS SCRIPT guide cover illustration

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.html containing the product list, cart, totals, and checkout controls.
  • Backend: a server-side file named Code.gs that reads products, validates sales, updates stock, and creates receipt numbers.
  • Database: a spreadsheet containing Products, Sales, SaleItems, and Settings sheets.

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

Architecture diagram showing a POS cashier web app connected to Apps Script and Google Sheets tabs
The browser handles the cart interface while Apps Script validates and saves each transaction.

Data flow

  1. The web page calls getInitialData() through google.script.run.
  2. Apps Script reads active rows from Products and returns plain objects to the browser.
  3. The cashier builds a cart locally. The browser calculates a preview, but the server recalculates all financial values.
  4. When checkout is submitted, saveSale() obtains a script lock, rereads current stock, validates the cart, and writes the transaction.
  5. 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

SheetPurpose
ProductsProduct catalog and current stock.
SalesOne row per completed transaction.
SaleItemsOne row per product line in a transaction.
SettingsStore 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

Infographic summarizing the Products, Sales, SaleItems, and Settings sheets used by a POS cashier
Separate catalog, sale header, sale-line, and configuration data to keep the checkout workflow maintainable.

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

ColumnMeaningExample
A: SKUUnique product codeCOF-001
B: NameDisplay nameGround Coffee
C: PriceUnit selling price8.50
D: StockAvailable quantity25
E: ActiveTRUE or FALSETRUE

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, Cashier
  • SaleItems: 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

Deployment flow from Apps Script files and authorization to a tested POS web app
Always verify the deployed URL with a small test sale after changing the project.

Save and authorize

  1. Save both Code.gs and index.html.
  2. From the Apps Script editor, run getInitialData once. Review the requested spreadsheet access and authorize the project with the account that owns or can edit the workbook.
  3. Confirm that the spreadsheet has the four required tabs and that the product data is valid.

Create a web app deployment

  1. Select Deploy → New deployment.
  2. Choose Web app as the deployment type.
  3. 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.
  4. Create the deployment, authorize any additional prompt, and copy the web app URL.
  5. 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 Sales and the correct line rows in SaleItems.
  • 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

Small shop cashier using a spreadsheet-backed Apps Script POS system with scale and access considerations
Apps Script works well for a focused small-shop workflow, but high-volume or regulated POS operations need stronger infrastructure.
  • 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

  1. Add a product administration screen: allow authorized users to create products, deactivate old SKUs, and record stock adjustments instead of editing cells manually.
  2. 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.
  3. 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.
  4. 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.
  5. Improve performance: read the catalog once, cache data for short periods where appropriate, and replace per-row spreadsheet writes with batch operations.
  6. Add reporting: create daily sales summaries, payment-method totals, low-stock alerts, and end-of-shift reports using separate functions or pivot tables.
  7. 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.

Continue learning

Related Apps Script guides