Alternate method of adding Manual Transactions (aka Adding manual transactions from your phone)

Overview

I’ve long struggled with adding manual transactions for several reasons:

  • Downloading transactions for my HSA is so unreliable it’s not worth the effort to even try. I just treat it as a manual account.
  • I track my grocery budget daily so waiting for the latest transaction to post and then download is too slow
  • I track my gas fill-ups separately per car, but if I don’t write down which car I filled up and when, I usually forget by the time the transaction downloads into Tiller
  • My main bank downloads use the posting date instead of the transaction date, which throws off my reporting when I’m trying to figure out how much I spent on a trip (the posting date can be anywhere from 1-5 days after the transaction happened)

While doing research into something else Google Sheets-related, I came across the idea of using a Google Form to import data into a Sheet, and I eventually developed a method to add manual transactions on demand from anywhere when I’m thinking about them.

The data flow works like this:

  1. Using saved URL on my phone, open a Form. Enter date, description, amount, account and tags (optional), then Submit Form (I also created an iPhone Shortcut to streamline filling in the Form, but that’s not necessary for this to work)
  2. The Form pushes the data into a table in my Tiller Sheet
  3. An event trigger then schedules the import to the Transactions table for 5 minutes later (this is so that if I have multiple manual transactions to enter, the import process only runs once and adds them all. Every new Form entry resets the timer to 5 minutes later)
  4. When the timer finishes counting down, a script runs that takes the Form data, looks up the account details (account number, institution), calculates date fields (month, week), gives it a manual transaction ID, and adds the metadata that indicates this is a manual transaction

I used to have to spend a few minutes each night to enter my transactions in Tiller, but now I can go days between having to open it. Another benefit is that using the Tiller extension to enter the manual transactions is pretty unpredictable. It can take anywhere from 3-90 seconds to enter a single transaction, which is killer when I have 5+ to enter. Now I enter them as needed throughout the day (usually no more than 10 secs per transaction), and by the time I get back to my Tiller sheet, they’re imported and ready.

Installation

  1. Create a Form to capture the data. If you’ve never worked with Google Forms before, here’s a tutorial on getting started: Create your first form in Google Forms - Google Workspace Learning Center
    1. Add the following fields:
      1. Date (date field)
      2. Description (short answer)
      3. Category (dropdown) - you don’t need to add ALL your categories, or the list will get too overwhelming. Just focus on the minimum needed to be useful.
      4. Amount (short answer, with Response Validation set to “number”)
      5. Account (dropdown) - same as Category, keep this short
      6. Tags (dropdown or short answer)
    2. Publish the Form (upper right of screen)
    3. Go to the Responses tab and select the 3 dots in the upper right. Choose “Select destination for responses”
    4. Select existing spreadsheet
    5. Select your Tiller sheet
    6. To test the Form, click the “Copy Responder Link” in the upper right (looks like a chain link), then open that URL in a separate browser window
    7. Fill in the form and submit it (the first time you do this is will probably ask for permission to access the spreadsheet)
    8. Open your Tiller spreadsheet, there should be a new tab with the Form results (rename the sheet and table name if desired)
      1. image
  2. Install the scripts
    1. If you’ve never used a script with Google Sheets, follow these steps to get started: Creating your first Apps Script
    2. Create a new script file and paste in this script:
      1. function scheduleAppendManualTransactions() {
          // Delete any existing delayed triggers to avoid duplicates
          const triggers = ScriptApp.getProjectTriggers();
          triggers.forEach(trigger => {
            if (trigger.getHandlerFunction() === "appendManualTransactions") {
              ScriptApp.deleteTrigger(trigger);
            }
          });
        
          // Create a new time-driven trigger to run in 5 minutes
          ScriptApp.newTrigger("appendManualTransactions")
            .timeBased()
            .after(5 * 60 * 1000) // 5 minutes in milliseconds
            .create();
        
          Logger.log(`appendManualTransactions trigger scheduled at: ${new Date(Date.now() + 5 * 60 * 1000)}`);
        }
        
        /*===============================================================================================
        =================================================================================================
        ===============================================================================================*/
        
        function appendManualTransactions() {
          // Access the active spreadsheet and relevant sheets
          const ss = SpreadsheetApp.getActiveSpreadsheet();
          const manualSheet = ss.getSheetByName("Manual Transactions"); //Name of the sheet with the Manual Transactions from the Form
          const transactionSheet = ss.getSheetByName("Transactions");   //Name of the Transactions sheet
        
          // Get all data from the Manual Transactions sheet
          const manualData = manualSheet.getDataRange().getValues();
          const headers = manualData[0]; // First row contains headers
        
          // Ensure "Processed" column exists; add it if missing
          let processedColIndex = headers.indexOf("Processed");
          if (processedColIndex === -1) {
            processedColIndex = headers.length;
            manualSheet.getRange(1, processedColIndex + 1).setValue("Processed");
            Logger.log(`Added "Processed" column at index ${processedColIndex}`);
          }
        
          // Ensure "Error Log" column exists; add it if missing
          let errorColIndex = headers.indexOf("Error Log");
          if (errorColIndex === -1) {
            errorColIndex = headers.length;
            manualSheet.getRange(1, errorColIndex + 1).setValue("Error Log");
            Logger.log(`Added "Error Log" column at index ${errorColIndex}`);
          }
        
          // Build a lookup map of account metadata from the Accounts sheet
          const accountMap = getAccountLookupMap();
        
          // Prepare arrays to store valid transactions and rows to mark as processed
          const output = [];
          const processedRows = [];
        
          // JSON strings used to flag reconciliation status
          const reconcileYes = '{"manualTransaction":{"reconcile":true,"ignoredIds":[]}}';
          const reconcileNo = '{"manualTransaction":{"reconcile":false,"ignoredIds":[]}}';
        
          // Loop through each row of manual data (excluding header)
          for (let i = 1; i < manualData.length; i++) {
            const row = manualData[i];
            const processedFlag = row[processedColIndex]; // Check if already processed
        
            if (!processedFlag) {
              // Destructure expected fields from the row
              const [timestamp, rawDate, description, category, amount, account, tags] = row;
        
              const missingFields = [];
        
              // Validate required fields
              const parsedDate = new Date(rawDate);
              if (!(parsedDate instanceof Date && !isNaN(parsedDate))) missingFields.push("Date");
              if (!description) missingFields.push("Description");
              if (!category) missingFields.push("Category");
              if (amount === "" || amount === null || isNaN(amount)) missingFields.push("Amount");
              if (!account) missingFields.push("Account");
        
              // If any required fields are missing, log error and skip row
              if (missingFields.length > 0) {
                const errorMessage = `Missing ${missingFields.join(", ")}`;
                manualSheet.getRange(i + 1, errorColIndex + 1).setValue(errorMessage);
                Logger.log(`Row ${i + 1} skipped: ${errorMessage}`);
                continue;
              }
        
              // Lookup account metadata
              const accountInfo = accountMap[account] || {};
        
              // Calculate derived fields
              const month = getFirstDayOfMonth(parsedDate);
              const week = getPreviousSunday(parsedDate);
              const reconcile = account === "Health Savings Account" ? reconcileNo : reconcileYes;
        
              // Get headers from Transactions sheet
              const transactionHeaders = transactionSheet.getRange(1, 1, 1, transactionSheet.getLastColumn()).getValues()[0];
        
              // Create a map of field values keyed by header name
              const transactionFields = {
                "Date": parsedDate,
                "Description": description,
                "Category": category,
                "Amount": amount,
                "Account": account,
                "Tags": tags,
                "Account #": accountInfo.accountNum || "",
                "Institution": accountInfo.institution || "",
                "Month": month,
                "Week": week,
                "Transaction ID": "manual:" + Utilities.getUuid(),
                "Account ID": accountInfo.accountId || "",
                "Full Description": description,
                "Date Added": timestamp,
                "Metadata": reconcile
              };
        
              // Build transaction row dynamically based on header order
              const transactionRow = transactionHeaders.map(header => transactionFields[header] ?? "");
                    output.push(transactionRow);
                    processedRows.push(i + 1); // Track row index for marking as processed
                    Logger.log(`Row ${i + 1} prepared for append`);
                  }
          }
        
          // If there are valid transactions to append
          if (output.length > 0) {
            const lastRow = transactionSheet.getLastRow();
            transactionSheet.getRange(lastRow + 1, 1, output.length, output[0].length).setValues(output);
            Logger.log(`Appended ${output.length} rows to Transactions sheet. Details:`);
        
            // Log each appended row for debugging
            output.forEach((row, index) => {
              Logger.log(`Row ${index + 1}: ${JSON.stringify(row)}`);
            });
        
            // Sort the Transactions sheet by Date (desc), Account (asc), Amount (asc)
            const transactionHeaders = transactionSheet.getRange(1, 1, 1, transactionSheet.getLastColumn()).getValues()[0];
            const sortConfig = [
              { key: "Date", ascending: false },
              { key: "Account", ascending: true },
              { key: "Amount", ascending: true },
              { key: "Description", ascending: true }
            ];
            const sortColumns = sortConfig.map(({ key, ascending }) => ({
              column: transactionHeaders.indexOf(key) + 1,
              ascending
            }));
        
            const numRows = transactionSheet.getLastRow() - 1;
            const numCols = transactionSheet.getLastColumn();
            transactionSheet.getRange(2, 1, numRows, numCols).sort(sortColumns);
            Logger.log(`Sorted Transactions sheet by ${sortConfig.map(c => c.key).join(", ")}`);
        
            // Mark processed rows with current timestamp
            const timestampValues = processedRows.map(() => [new Date()]);
            manualSheet.getRange(processedRows[0], processedColIndex + 1, processedRows.length, 1).setValues(timestampValues);
            Logger.log(`Marked ${processedRows.length} rows as processed`);
          } else {
            Logger.log("No valid rows to append");
          }
        }
        
        /*===============================================================================================
        =================================================================================================
        ===============================================================================================*/
        
        function getAccountLookupMap() {
          const ss = SpreadsheetApp.getActiveSpreadsheet();
          const accountSheet = ss.getSheetByName("Accounts"); // Name of the Accounts sheet
          const accountData = accountSheet.getDataRange().getValues();
          const headers = accountData[0];
        
          // Map header names to column indices
          const headerMap = headers.reduce((map, header, i) => {
            if (header === "Account") map.accountCols.push(i); // Track multiple "Account" columns
            else map[header] = i;
            return map;
          }, { accountCols: [] });
        
          // Use second "Account" column for lookup
          const accountColIndex = headerMap.accountCols[1];
          const {
            "Unique Account Identifier": uniqueIdColIndex,
            "Account Id": accountIdColIndex,
            "Account #": accountNumColIndex,
            "Institution": institutionColIndex
          } = headerMap;
        
          // Validate required columns exist
          if ([accountColIndex, uniqueIdColIndex, accountIdColIndex, accountNumColIndex, institutionColIndex].includes(undefined)) {
            throw new Error("Missing required columns in Accounts sheet");
          }
        
          // Build account metadata map
          const accountMap = {};
          for (let i = 1; i < accountData.length; i++) {
            const accountName = accountData[i][accountColIndex];
            accountMap[accountName] = {
              uniqueId: accountData[i][uniqueIdColIndex],
              accountId: accountData[i][accountIdColIndex],
              accountNum: accountData[i][accountNumColIndex],
              institution: accountData[i][institutionColIndex]
            };
          }
        
          Logger.log(`Built account map with ${Object.keys(accountMap).length} entries`);
          return accountMap;
        }
        
        /*===============================================================================================
        =================================================================================================
        ===============================================================================================*/
        
        // Returns the first day of the month for a given date
        function getFirstDayOfMonth(date) {
          return new Date(date.getFullYear(), date.getMonth(), 1);
        }
        
        /*===============================================================================================
        =================================================================================================
        ===============================================================================================*/
        
        // Returns the previous Sunday for a given date
        function getPreviousSunday(date) {
          const offset = date.getDay(); // 0 = Sunday
          const sunday = new Date(date);
          sunday.setDate(date.getDate() - offset);
          return sunday;
        }
        
        
        
        
        
        
    3. Save the script
  3. Add the trigger
    1. Move to the Triggers section of Apps Script (on the left side of the screen, looks like an alarm clock)
    2. Click Add Trigger (blue button in the bottom right)
    3. Fill it in like this:
      1. If you don’t want to schedule the append function to run later, and instead want it to run immediately every time you add a transaction, choose “appendManualTransactions” in the first box
    4. Click Save

That’s it for installation of the project.

Configuration

There are a few spots for customization:

  • Line 13: set the delay for the append script to run. Default is 5 minutes
    • .after(5 * 60 * 1000) // 5 minutes in milliseconds
      
  • Lines 26 & 27: the names of the sheet the Form is storing its data, and the Transactions sheet
    • const manualSheet = ss.getSheetByName("Manual Transactions"); //Name of the sheet with the Manual Transactions from the Form
      const transactionSheet = ss.getSheetByName("Transactions");   //Name of the Transactions sheet
      
  • Line 92: if you have an account that you DON’T want to be included in the reconciliation process, include it here
    • const reconcile = account === "Health Savings Account" ? reconcileNo : reconcileYes;
      
  • Lines 98 - 114: if you modified any of the core Transactions sheet columns, update these to match
    • const transactionFields = {
              "Date": parsedDate,
              "Description": description,
              "Category": category,
              "Amount": amount,
              "Account": account,
              "Tags": tags,
              "Account #": accountInfo.accountNum || "",
              "Institution": accountInfo.institution || "",
              "Month": month,
              "Week": week,
              "Transaction ID": "manual:" + Utilities.getUuid(),
              "Account ID": accountInfo.accountId || "",
              "Full Description": description,
              "Date Added": timestamp,
              "Metadata": reconcile
            };
      
  • Lines 137 - 142: this script sorts the Transactions sheet to bring the new manual transactions into the table. Modify the sort order if you like.
    • const sortConfig = [
            { key: "Date", ascending: false },
            { key: "Account", ascending: true },
            { key: "Amount", ascending: true },
            { key: "Description", ascending: true }
          ];
      
  • Line 168: name of the core Accounts sheet
    • const accountSheet = ss.getSheetByName("Accounts"); // Name of the Accounts sheet
      
  • Lines 181 - 186: the names of the core Accounts columns
    • const {
          "Unique Account Identifier": uniqueIdColIndex,
          "Account Id": accountIdColIndex,
          "Account #": accountNumColIndex,
          "Institution": institutionColIndex
        } = headerMap;
      

Usage

If everything is working, all you need to do is open the Form on your desktop or phone, and fill in the questions and hit Submit:

The Form data will appear in the Manual Transactions table:

image

Five minutes later it should appear in the Transactions table:

image

Streamlining Filling in the Form

I did this project specifically because I wanted to enter health-related transactions for my HSA, and to record grocery and gas expenses. Thankfully, Google Forms allows you to create pre-filled URLs for your Form, so I saved shortcuts for each of those scenarios.

I also used the iPhone Shortcuts app to create shortcuts that guide me through each of those scenarios, presenting a limited number of categories, accounts, and tags depending on which branch I chose. It’s so smooth and easy to enter the details (maybe 10 seconds per transaction) but creating the shortcuts was unbelievably tedious. Given the power available in this app, I can’t believe there isn’t a better way to build these. This link (Sending Data to Google Sheets Using Apple Shortcuts | thiagoalves.ai) is a great guide to setting it up, however.

I too track gas expense by vehicle. I just make a note on the receipt when I get in the car after filling up. Actually I write the spending category on all my receipts and store them alphabetically by store/vendor in an accordian file. If something happened to my google sheet I at least have all my categorization work saved on paper. I know some people are not into getting receipts, but it works for me. Also I check Tiller and do my categorizations daily. So I can usually remember my spending from the last day or two.

Thanks immensely for this useful tool. I have small cash expenses here and there that I never did bother to track because of too much effort involved. I implemented your solution and it worked beautifully!

1 Like

Thank you! This solves a problem tracking my daily expenses. It is often 5-6 days before transactions post and I can go back and categorize. Meanwhile my “envelopes” are very incorrect.
I have run into a couple issues.

  1. The month is coming out as the last day of the previous month.

  2. I have added a custom column on my spreadsheet labeled “note”. I have a “note” question on my form. I have not figured out how to adjust the script to move the “note” to my transactions sheet, however “note” is showing up in the account column on the transactions sheet.

HI! To add a Note column, do this:

  1. In line 63 of the script, add the Note column name in the order it appears in the Manual Transactions table
  2. In lines 100-114, add a line for the Notes column. The bit in the quotes is the column name on Transactions sheet, and the bit after the colon is the variable name you gave in line 63. Ex:
    1. “Note”: note || ““,

For the date issue, what format is the date being stored in the Manual Transactions table?

Thank You.
That fixed the note column issue.
The timestamp is “10/22/2025 16:49:11” the date “10/22/2025”. I am in MDT time zone.

As a side note, and there may be a smoother way to do this, I am using conditional formatting to highlight the row in transactions that comes from manual transactions. Then when the transaction comes through I can easily reconcile and delete. I tend to delete the imported transaction because the manual transaction has more information specific to my needs.

I wonder if the timezone is messing things up. Try adding this line after line 72 (const parsedDate = new Date(rawDate);):

parsedDate.setHours(0,0,0,0);

As for reconciling downloaded transactions with the manual ones, have you tried using the Reconcile Transactions feature of the Tiller Community Solutions extension? It matches downloaded transactions with manual ones and overwrites the manual transaction ID with the “official” transaction ID from Tiller, then deletes the downloaded one for you. It does a lot of the work for you.

Let me know if setting the time helps.

I added that line of code. It did not make a difference on the month. It still shows last day of previous month.
I did try the reconcile transactions tool. At first it would not work, but after digging in a bit, I think I figured out the problem with an account being both a manual account and automatic account. It now works. Thanks for pointing it out.