A Google Apps Script tool for HOAs, small organizations, and teams that automatically reads receipts and invoices from Google Drive, extracts key information, and logs everything to a Google Sheet. No server, no backend, no monthly fees — it runs entirely inside your Google account.
- How it works
- What gets captured
- Architecture overview
- File reference
- Prerequisites
- Deployment: simple path (no API key required)
- Deployment: Vision API path (better OCR accuracy)
- Script properties reference
- Supported file types
- Recommended Drive folder structure
- Human corrections and ML training
- Extending the code
- Future enhancements
- Security note aka Gemini's Sneak Diss
- You drop receipt or invoice files into an Inbound folder in Google Drive.
- Run
processReceipts()from the Apps Script editor (or set up a time-based trigger to run it automatically). - Each file is read using OCR, parsed for date, vendor, amount, description, and category, then logged as a row in a Google Sheet.
- The original file is moved to a Processed folder.
- A second sheet (
OCR_Log) stores the raw extracted text for auditing.
Duplicate files are detected automatically via a hash of the OCR text and skipped without re-processing.
| Column | Description |
|---|---|
| Date | Transaction date extracted from the receipt. Blank if no date was found (see confidence score). |
| File Name | Original filename in Google Drive |
| Vendor | Vendor name, taken from the top of the document. Skips lines that look like email addresses or phone numbers. |
| Description | Description of service or item |
| Category | Expense category assigned by keyword matching (see category_classifier.js) |
| Amount | Total transaction amount, selected using keyword scoring heuristics |
| Confidence | Score from 0–100 indicating parser confidence. Penalized when date is missing or amount keywords are absent. |
| Uploader | Google Drive owner of the file |
| File URL | Direct link to the original file in Drive |
| Hash | MD5 hash of the OCR text, used for deduplication |
| Human Correction | Field for reviewer corrections. Used to build ML training data — see Human corrections and ML training. |
| Column | Description |
|---|---|
| File Name | File that was processed |
| OCR Text | Full raw text extracted by OCR |
| Logged At | Timestamp of processing |
+------------------+
| main.js | processReceipts() — entry point
+------------------+
|
v
+----------------------+
| DocumentProcessor | orchestrates OCR + parsing + post-processing
+----------------------+
| |
v v
+------------------+ +--------------------+
| OCRService | | ParserFactory | selects parser based on PROCESSOR_MODE
+------------------+ +--------------------+
| | |
v v v
+-------+ +----------+ +-------------------+
| Drive | | Vision | | VisionParser | rule-based extraction
| OCR | | Service | | (parser_rule_ |
+-------+ +----------+ | based.js) |
+-------------------+
|
v
+------------------+
| SheetService | writes to Receipts and OCR_Log sheets
+------------------+
OCR_MODE = DRIVEuses Google Drive's built-in OCR (free, no setup).OCR_MODE = VISIONuses the Google Cloud Vision API (better accuracy on photos and low-quality scans, requires an API key).PROCESSOR_MODE = OCR_RULEis the current active mode.DOCUMENT_AIis stubbed for future use.
Entry point. Call processReceipts() to start a run.
| Function | Description |
|---|---|
processReceipts() |
Iterates inbound Drive files, orchestrates processing, deduplication, sheet writes, and file moves |
Orchestrates a single file through OCR, parsing, and post-processing. Returns a structured receipt object or null if extraction fails.
| Function | Description |
|---|---|
DocumentProcessor.process(file) |
Selects parser via ParserFactory, runs OCR, parses text, assigns hash, category, filename, and URL |
Returns the correct parser instance based on PROCESSOR_MODE. Add new parser implementations here.
| Function | Description |
|---|---|
ParserFactory.getParser() |
Returns VisionParser for OCR_RULE mode. Throws a clear error for unknown or unimplemented modes. |
Rule-based parser that extracts structured fields from raw OCR text using keyword heuristics and regex.
| Function | Description |
|---|---|
VisionParser.parseFromText(text, file) |
Main parse entry point. Returns a receipt object with all fields. |
VisionParser.extractDate(text) |
Searches for date keywords first, then falls back to any date pattern. Returns blank string if not found. |
VisionParser.extractVendor(text) |
Returns the first non-email, non-phone line from the top of the document |
VisionParser.extractDescription(text) |
Finds lines matching description keywords and strips the label |
VisionParser.calculateConfidence(text, date) |
Scores 0–100 based on presence of amount keywords. Applies a 20-point penalty if no date was found. |
Routes files to the appropriate OCR backend based on file type and OCR_MODE.
| Function | Description |
|---|---|
OCRService.extractText(file) |
Dispatches to Drive OCR, Vision API, or native text reading based on MIME type |
OCRService.extractWithDrive(file) |
Copies the file as a Google Doc, extracts body text, then always deletes the temp doc (even if extraction fails) |
OCRService.extractWithVision(file) |
Delegates to VisionService |
Calls the Google Cloud Vision API for OCR. Only used when OCR_MODE = VISION.
| Function | Description |
|---|---|
VisionService.extractText(file) |
Base64-encodes the file and sends it to the Vision DOCUMENT_TEXT_DETECTION endpoint. Checks both top-level and per-image errors. |
All Google Sheets interactions.
| Function | Description |
|---|---|
SheetService.getReceiptsSheet() |
Returns (or creates) the Receipts sheet, adding a header row if empty |
SheetService.getLogSheet() |
Returns (or creates) the OCR_Log sheet |
SheetService.appendReceipt(sheet, receipt) |
Appends a parsed receipt as a new row |
SheetService.logOCR(sheet, fileName, ocrText) |
Appends a raw OCR entry to the log sheet |
SheetService.getExistingHashes(sheet) |
Returns a Set of all hash values already in the sheet, for deduplication |
Handles Drive folder access. Throws a descriptive error if required folder ID properties are not set.
| Function | Description |
|---|---|
DriveService.getInboundFiles() |
Returns a file iterator for the inbound folder |
DriveService.getProcessedFolder() |
Returns the processed folder object |
Classifies an expense into a category by matching keywords in the OCR text and vendor name.
| Function | Description |
|---|---|
CategoryClassifier.classify(text, vendor) |
Returns a category string: Utilities, Landscaping, Maintenance, Cleaning, Insurance, or Other |
To add new categories, add a new if block with your keywords before the return "Other" line.
Lightweight logging with optional Sheet output. Log level and sheet logging are controlled via Script Properties.
| Function | Description |
|---|---|
LoggerService.info(msg) |
Logs at INFO level (shown when LOGGER_LEVEL >= 3) |
LoggerService.warn(msg) |
Logs at WARN level (shown when LOGGER_LEVEL >= 2) |
LoggerService.error(msg) |
Logs at ERROR level (shown when LOGGER_LEVEL >= 1) |
| Function | Description |
|---|---|
Utils.generateHash(text) |
Returns an MD5 hex string of the input. Used to fingerprint OCR text for deduplication. |
Central configuration constants. If you want to change sheet names, default modes, keywords, or confidence settings without touching Script Properties, edit this file.
- A Google account (personal Gmail works; Google Workspace is not required). See Security note aka Gemini's Sneak Diss
- A Google Sheet where receipts will be logged — create a blank one before deploying
- Two Google Drive folders: one for inbound files, one for processed files
- The folder IDs for both (copy from the URL when you open a folder in Drive:
https://drive.google.com/drive/folders/THIS_PART)
For the Vision API path only:
- A Google Cloud project with the Cloud Vision API enabled
- An API key with access to the Vision API
This path uses Google Drive's built-in OCR. It works well for clean PDFs and Google Docs. For blurry photos or low-quality scans, see the Vision API path below.
Step 1 — Create a Google Sheet
- Create or open the Google Sheet for your tracker.
- Click Extensions > Apps Script. This opens a separate Apps Script project bound to that sheet.
Step 2 — Copy the script files into Apps Script
- Continuing Step 1 (You can also go to script.google.com)
- Delete the default
Code.gsfile. - For each
.jsfile in this repository, click + > Script and paste in the file contents. Name the script file to match (e.g.main,document_processor, etc.). - Click Save.
The simplest setup is to open your Google Sheet and go to Extensions > Apps Script. This creates a project that is automatically linked to your sheet — no additional configuration needed to connect to it.
Step 3 — Set Script Properties
- In the Apps Script editor, click Project Settings (gear icon) > Script Properties.
- Add the following properties:
| Property | Value |
|---|---|
INBOUND_FOLDER_ID |
ID of your inbound Drive folder |
PROCESSED_FOLDER_ID |
ID of your processed Drive folder |
OCR_MODE (optional) |
DRIVE |
PROCESSOR_MODE (optional) |
OCR_RULE |
ML_MODE (optional) |
OFF |
Step 4 — Authorize the script
- In the Apps Script editor, select
processReceiptsfrom the function dropdown and click Run. - Google will ask you to authorize the script. Click Review permissions and grant access to Drive and Sheets.
Step 5 — Run it
- Drop one or more receipt files into your inbound folder.
- Click Run in the Apps Script editor with
processReceiptsselected. - Open your Google Sheet — you should see new rows in the
Receiptstab and raw OCR text inOCR_Log.
Optional: run automatically on a schedule
- Click Triggers (clock icon) in the left sidebar.
- Click + Add Trigger.
- Set function to
processReceipts, event source to Time-driven, and choose your interval (e.g. daily).
⚠️ Temporary Google Docs are created during Drive OCR and deleted automatically after text is extracted. If a run is interrupted, you may occasionally find orphaned docs namedtemp_ocr_...in your Drive root — these can be deleted manually.
Use this path when you need better results on photos of receipts, handwritten notes, or low-quality scans. Complete all steps from the simple path first, then continue here.
Step 1 — Create a Google Cloud project
- Go to console.cloud.google.com.
- Click Select a project > New Project. Give it a name and click Create.
Step 2 — Enable the Vision API
- In the Cloud Console, go to APIs & Services > Library.
- Search for Cloud Vision API and click Enable.
Step 3 — Create an API key
- Go to APIs & Services > Credentials.
- Click + Create Credentials > API key.
- Copy the key. Optionally click Restrict key and limit it to the Cloud Vision API to reduce risk if the key is ever exposed.
⚠️ Do not paste your API key into the script files or commit it to source control. Always store it in Script Properties.
Step 4 — Add the key to Script Properties
In the Apps Script editor, go to Project Settings > Script Properties and add or update:
| Property | Value |
|---|---|
VISION_API_KEY |
Your API key from step 3 |
OCR_MODE |
VISION |
All other properties remain the same as the simple path.
The script will use Vision API for images and PDFs, and automatically fall back to Drive OCR if Vision returns no result.
| Property | Required | Default | Description |
|---|---|---|---|
INBOUND_FOLDER_ID |
Yes | — | Google Drive folder ID for inbound files |
PROCESSED_FOLDER_ID |
Yes | — | Google Drive folder ID for processed files |
OCR_MODE |
No | VISION |
DRIVE for Drive OCR (free, no API key), VISION for Vision API |
PROCESSOR_MODE |
No | OCR_RULE |
OCR_RULE is the only active mode. DOCUMENT_AI is reserved for future use. |
ML_MODE |
No | OFF |
Reserved for future ML-assisted parsing |
VISION_API_KEY |
Only if OCR_MODE=VISION |
— | Google Cloud Vision API key |
LOGGER_LEVEL |
No | 3 |
1 = errors only, 2 = warnings + errors, 3 = all |
LOGGER_SHEET_ENABLED |
No | OFF |
Set to ON to write log entries to the OCR_Log sheet |
| Type | Drive OCR | Vision API |
|---|---|---|
| Yes | Yes | |
| JPEG / PNG | Yes | Yes |
| Google Docs | Yes (native) | No |
| Microsoft Word (.docx) | Yes | No |
| Plain text / CSV / Markdown | Yes (direct read) | No |
/Shared Expense Tracker
├── Inbound Receipts ← upload files here before running
│ ├── HOA_Invoice_01.pdf
│ ├── Vendor_Receipt_2026-03-15.jpg
│ └── ...
└── Processed Receipts ← files are moved here automatically after processing
You can name these folders anything you like — what matters is that you copy the correct folder IDs into your Script Properties.
The Human Correction column in the Receipts sheet serves two purposes: fixing individual rows that the parser got wrong, and building a labeled dataset for future ML improvements. You don't actually need to do this, but it sets you up with training data for later. Fun for everyone.
Use key:value pairs, one per line. Only include fields that were wrong — leave the column blank if the row looks correct.
date:2026-03-15
vendor:Pacific Gas & Electric
amount:142.50
category:Utilities
Valid keys: date, vendor, description, amount, category, reject
Examples:
The vendor was parsed incorrectly:
vendor:Acme Landscaping Co.
The amount and category were both wrong:
amount:89.00
category:Maintenance
The OCR quality was too poor to produce any reliable data — exclude this row from training entirely:
reject:true
The row was parsed correctly — leave the Human Correction column blank.
A plain YES/NO flag tells you a row is wrong but loses the ground truth — you'd have nothing useful to train on. Free text is readable but can't be parsed reliably by a script. The key:value format is easy enough for a non-technical reviewer to fill in and structured enough to be read programmatically into training examples.
A future export script will read the sheet and produce labeled examples structured like this:
{
"input": "(raw OCR text from the OCR_Log sheet)",
"output": {
"date": "2026-03-15",
"vendor": "Pacific Gas & Electric",
"amount": 142.50,
"category": "Utilities"
},
"label": "corrected"
}The export logic:
- Start with the parser's original output for the row
- Overlay any corrections from the Human Correction column
- Mark corrected rows as
"label": "corrected", untouched high-confidence rows as"label": "clean" - Skip rows where
reject:trueis set
Over time, corrected rows from real receipts in your organization become domain-specific labeled data — exactly what makes fine-tuning a model worthwhile for your specific use case.
- After each processing run, filter the Receipts sheet to rows where Confidence is below 70.
- Open the original file using the File URL column and compare it to what was extracted.
- Fill in the Human Correction column for any fields that are wrong.
- Add
reject:truefor rows where OCR failed entirely.
You do not need to correct every row. A small number of high-quality corrections on low-confidence rows is more useful for training than trying to review everything.
Add a new expense category
Open category_classifier.js and add a new if block before return "Other":
if (/plumbing|pipe|drain/.test(t)) return "Plumbing";Add a new parser
- Create a new file (e.g.
parser_document_ai.js) that exports an object with aparseFromText(text, file)method returning the same receipt object shape asVisionParser. - Add a new
casetoparser_factory.jsthat returns your new parser. - Set
PROCESSOR_MODEin Script Properties to your new mode name.
Change the sheet column layout
Edit the header row in SheetService.getReceiptsSheet() and the appendReceipt() function in sheet_service.js. Keep the column order consistent between both places.
Enable sheet-level logging
Set LOGGER_SHEET_ENABLED = ON in Script Properties. Log entries will be appended as rows to the OCR_Log sheet in addition to the Apps Script console.
- ML-assisted parsing for vendor and description fields using collected human corrections
- Export script for generating structured ML training datasets from the Receipts sheet
- Integration with Google Document AI (
DOCUMENT_AIprocessor mode) - Customizable confidence thresholds per field
- Multi-language receipt support
It is highly recommended to create a new google account to run this script. This can be a bot/service account. The script needs access only to the spreadsheet where it executes, the folders it handles, a temporary file it creates, and API if you're using one. However, Google's security model wants the executing ID to have access to all of your documents on your Google Drive. If you don't want to give away the keys to the kingdom, setup a new google account.
Snippets from Google Gemini when asked about limiting script access. Note, I never used the word "scary."
How to Make the Permission Less "Scary"
If you don't like the script having access to "All your Google Sheets," you can restrict it to only the spreadsheet you are currently in.
Later, when limiting the permissions to just the active file didn't work.
That error is appearing because the @OnlyCurrentDoc tag we added earlier is doing its job too well. It tells Google to only grant access to the specific spreadsheet the script is attached to, which explicitly blocks the script from opening other files like Google Docs or Word files by their ID
To answer your first question: Yes, that scope is essentially a "master key" for every Google Doc you have access to. It is not limited to the files in your specific folder.
To your second question: Unfortunately, Google Apps Script does not offer a "Folder-only" OAuth scope. The Google security model is generally designed around "Services" (All Drive, All Docs, All Sheets) rather than "Location" (This Folder only). This is a common point of frustration for developers looking to follow the "Principle of Least Privilege."
How to Handle Security with 3+ Users
Since you are working in a Shared Drive with a small group, here is the standard "Best Practice" for this scenario:
Trust the Code, Not the Scope: The script technically has the authority to see all files, but it only has the instructions to look at your specific inboundFolderId. As long as you (the developer) don't write code that deletes your whole Drive, the "master key" stays safely in the drawer.
Use a Dedicated "Bot" Account (Optional): If you are very concerned about security, some teams create a dummy Google Account (e.g., receipt-bot@yourdomain.com). You give that bot account "Contributor" access only to the specific Shared Drive folders. You then "own" and run the script from that account. That way, even with a broad "All Files" scope, the script's reach is physically limited to what that bot account can see.
Internal Visibility: Since this script is only shared with 3 specific people and not published to the web, the "Unverified App" warning you saw earlier is actually your primary security layer. It ensures no one outside your group can accidentally authorize the script.
I went with the separate bot account, despite Gemini's sneak diss.