← Back to Site Map

🧾 Accounts Payable Variable

Standard Operating Procedure — Invoice ingestion, AI matching, approval workflow, and payment tracking
Version 2.19 • 14 Aug 2026 • Owner: Kevin Brittain

⚡ Quick Reference

Airtable Table
Dashboard Invoices — tblkOTKIG2Tyiy9aM
Airtable Base
appnqjDpqDniH3IRl
Gmail Label (Unpaid)
3: to pay — synced every 15 min via Apps Script. The script finds it by its leading "3", so "3. to pay" and "3: to pay" both work.
Gmail Label (Paid)
4: paid — emails moved here when marked paid
Dashboard Access
Leadership Dashboard → sidebar FinanceAccounts → the AP Variable subtab
localStorage Key
lastSeenInvoiceTime — tracks new-invoice badge
Amount Match Tolerance
±£0.02 exact amount match required for AI suggestions
Data Refresh Interval
15 minutes (smart refresh — defers while user is active)

📊 1. Overview & Purpose

The Accounts Payable Variable is the single place to see, manage, and pay all outstanding supplier invoices. It sits inside the Leadership Dashboard and connects three systems: Gmail (where invoices arrive), Airtable (the source of truth for all invoice data), and the Airtable Transactions table (bank transactions used for AI-assisted payment matching).

The main goals of the module are:

  • Give a live view of every invoice that still needs paying, sourced from Gmail label "3. to pay"
  • Allow inline editing of missing fields (amount, payee, description, reference) that save directly to Airtable
  • Surface AI-generated match suggestions when a bank transaction matches an invoice — allowing one-click reconciliation
  • Mark invoices as paid in one click, which simultaneously updates Airtable, moves the Gmail label, and links the matched bank transaction
ℹ️ Airtable is the source of truth. Nothing is stored in browser localStorage for invoice data. All edits, match decisions, and payment records are written directly to Airtable and persist across browsers and devices. The only localStorage key used is lastSeenInvoiceTime, which controls the "NEW" badge on the AP Variable subtab — this is purely cosmetic.
End-to-End Data Flow
📧Gmail"3. to pay" label
Apps Script15-min sync
🗃AirtableSource of truth
📋DashboardView, edit & pay
🤖AI MatchingBank transaction link

🔎 2. How to Access

  1. Open the Leadership Dashboard at index.html and log in with your Airtable Personal Access Token (PAT).
  2. In the left sidebar open the Finance section and click Accounts (the banknote icon 💷). There is no "Accounts Payable Variable" item in the sidebar itself.
  3. A row of subtabs appears across the top of the page: AR Fixed, AR Variable, AP Fixed, AP Variable, Transactions, Bank Accounts. Click AP Variable. The tab becomes active and renderInvoiceTab() runs immediately to display the current invoice list.
  4. The tab also loads whenever you navigate back to it from another tab — it always re-renders from the latest in-memory Airtable data. Data is fetched from Airtable every 15 minutes automatically (smart refresh defers if you are actively typing or clicking).
💡 The badge sits on the AP Variable subtab button, not in the sidebar. It shows the number of unpaid invoices in grey, and switches to a red badge (e.g. 5 • 2 new) when invoices have arrived since you last viewed the tab. The badge clears to grey once you open the tab.
AP Variable Subtab Badge Behaviour

The badge is controlled by updateInvoicesSidebarBadge() and compares each invoice's Airtable createdTime against the lastSeenInvoiceTime stored in localStorage.

Badge appearanceMeaning
5 • 2 new5 unpaid invoices total; 2 arrived since you last viewed the tab
33 unpaid invoices; none are new since last view
(blank)No unpaid invoices — all are paid or none exist

📋 3. Invoice List & Filtering

The Invoices tab shows the records from Airtable's Dashboard Invoices table where {Status}='Unpaid'. The list is fetched with invoices sorted by Email Date ascending. Anything with another status, including Paid and Estimate, is left out of the fetch and never reaches the table. Above the table are four KPI summary cards, then filter/sort controls, and then the invoice rows.

KPI Summary Cards

The four cards are always calculated from all unpaid invoices, regardless of any active filter.

Unpaid Invoices

Total count of invoices where Status is not "Paid". Subtitle: "Synced from Gmail '3. to pay'"

Known Amount Due

Sum of all unpaid invoices that have an amount entered. Shown in red. Subtitle shows how many of the total have amounts.

Amount Unknown

Count of invoices where the Amount field is still blank. Subtitle suggests clicking to enter amounts. Shown in grey if zero.

Overdue / Past Date

Count of unpaid invoices where the effective date (Due Date if set, otherwise Email Date) is before today. Shown in red.

Sort Dropdown (invSortBy)

Located above the table. Changing the selection immediately re-renders the list via renderInvoiceTab(). Sort is applied after the text filter.

Option valueLabelSort logic
dateDate (oldest first)Effective date ascending — default
date-descDate (newest first)Effective date descending
amountAmount (high→low)Amount descending; nulls treated as 0
amount-ascAmount (low→high)Amount ascending; nulls treated as 0
payeePayee (A→Z)Payee name locale-compare ascending
ℹ️ The "effective date" used for date sorting is the Due Date field if populated, otherwise the Email Date. The same date is used to calculate overdue status and the date displayed in the table.
Text Filter (invFilterText)

A search input that filters the visible rows in real time as you type (oninput event). The search is case-insensitive and matches against three fields simultaneously:

  • Payee name
  • Description
  • Reference

Clearing the search box restores the full (sorted) list. The KPI cards are not affected by the filter — they always show totals for all unpaid invoices.

A Clear filters button sits to the right of the search box. It empties the search box only. The Sort by dropdown keeps whatever you chose, and any rows you have ticked stay ticked.

Table Columns
#Column headerDescription
1(tick box)Selects the row for a bulk action. The tick box in the header row selects or clears every row currently visible. See section 7.
2#Row index (1-based). Grey, small.
3Due / DateEffective date in bold (DD Mon YYYY format), a 📧 link that opens the original email in Gmail in a new tab, and the status badge beneath (see badge table below).
4PayeeSupplier name in an always-on text box. Empty boxes show the placeholder "Payee…".
5DescriptionInvoice description or email subject, always-on text box. Placeholder "Description…".
6RefPayment reference, always-on text box in monospace. Placeholder "Ref…".
7AmountInvoice amount, always-on box, right-aligned, shown to two decimal places. Placeholder "0.00".
8BusinessWhich business the invoice relates to. An always-on dropdown listing the ACTIVE businesses only, plus a "None" option. Stored as a linked record on the Dashboard Invoices table. If the invoice already points at a business that has been switched off, that option is still shown so the value is not lost.
9(Actions)Contains the "Mark Paid" button.
💡 To read the original email, click the small 📧 envelope next to the date. It opens the Gmail thread in a new tab, which is useful for checking the invoice PDF before paying. Clicking elsewhere on the row does not open Gmail, because every cell is now an editable box.
Row Status Badges

Each invoice row shows a coloured badge beneath the date. The badge is determined by the invoice's effective date and the isEstimate flag:

BadgeCondition
EstimateisEstimate field is true — shown regardless of date
OverdueEffective date is before today and not an estimate
Due soonEffective date is within 7 days from today
UpcomingEffective date is more than 7 days away
NEWInvoice was created in Airtable after you last viewed the tab (compared via lastSeenInvoiceTime). Can appear alongside another badge.
Sync Health Indicator

Beneath the "Last refreshed" text is a sync health indicator (invSyncHealth). It compares the number of unpaid invoices in the dashboard against the Gmail label count fetched from the Apps Script (?action=count). Three states:

  • In sync — dashboard and Gmail counts match
  • Mismatch — counts differ; may indicate a sync delay or labelling discrepancy
  • Dashboard only — Gmail count could not be fetched (Apps Script not reachable)
⚠️ The Gmail figure stops counting at 100. The count endpoint asks Gmail for at most 100 threads, so a label holding 100 or more always reports exactly 100. If your unpaid count is above 100 the indicator will read Mismatch permanently, and the number it shows is a floor, not an exact figure. Below 100 the comparison is trustworthy.
The two links in the "Last refreshed" bar

The bar above the table holds two links that do very different jobs. Read this before clicking either.

LinkWhat it doesCan it change an invoice?
Refresh from Gmail Calls triggerGmailInvoiceSync(), which asks the Apps Script to read the newest 50 threads in the "3: to pay" label and create a record for any invoice email it has not seen. After 3 seconds the dashboard re-reads Airtable. The link text shows "Syncing…" for 4 seconds. It only ADDS new invoices. It never changes the status of an invoice you already have.
Reconcile with Gmail Calls triggerGmailInvoiceReconcile(), which re-points every invoice status at what the Gmail label says right now. Yes. It can flip invoices to Paid AND flip Paid invoices back to Unpaid, in the same run. Read section 6 first.

Next to the two links is the sync health text described above.

⚠️ Refresh from Gmail only looks at the newest 50 emails in the label. An invoice email older than the most recent 50 will never appear, however many times you click it, and nothing tells you it was skipped. To pull in an older one: open the email in Gmail, remove the "3: to pay" label, re-apply it so the email jumps back to the top of the label, then click Refresh from Gmail. Failing that, add the invoice to Airtable by hand.
The sync bar at the top of the tab

Above the invoice summary cards sits the standard platform sync bar. It is the same bar every page in the platform carries, and it does two things here.

  • Its Refresh button does the same job as "Refresh from Gmail" and then re-reads Airtable in one action. Like that link, it can only ADD invoices; it never changes the status of an invoice you already have. It is safe to click at any time.
  • Its checks pill shows a pass/warn/fail count. Click it to open the drawer and read each check. Seven checks run: invoices fetched from Airtable, the outstanding count, whether every invoice still links back to its Gmail thread, whether the invoice-to-transaction matcher runs, whether the Gmail Apps Script URL is configured, whether the AP Variable badge is wired up, and the result of your last Mark Paid or AI-match approval.
ℹ️ This is where a failed Mark Paid is reported. If a Mark Paid or an AI-match approval fails, the last check turns red and names the time and the error. The row on screen may look unchanged, so the drawer is the place to look before assuming the click worked. See section 5 card 5 for the buttons themselves.

4. Invoice Status Lifecycle

Full Lifecycle Flow
📧Email arrivesGmail "3. to pay"
Apps Script syncsCreates Airtable record
Status = Unpaid
User edits fieldsAmount, Payee, Ref
saved to Airtable
🤖AI match foundSuggestion row
appears in table
Marked PaidStatus = Paid
Gmail → "4: paid"
Status Values

The Status field (fldJ5InUPlY4t7MgP) in Airtable drives what you see:

StatusMeaningVisible on dashboard?
UnpaidDefault state when created by Apps Script. Invoice needs attention.Yes, this is the only status the table shows
PaidSet when "Mark Paid" is actioned, on its own or in bulk. Paid Date is set at the same time.No, the fetch asks for {Status}='Unpaid' only
EstimateSet by hand in Airtable for a quote you do not want treated as a bill. Reconcile with Gmail ignores these records completely.No, same reason as Paid
ℹ️ Paid invoices remain in Airtable permanently. They are only hidden from the dashboard view by the Airtable filter formula. You can view them directly in Airtable at any time for audit or reporting purposes.
⚠️ Paid is not a one-way door. "Reconcile with Gmail" can move an invoice from Paid back to Unpaid and clear its Paid Date, if the email is still sitting in the "3: to pay" Gmail label. Section 6 explains exactly when that happens.

👉 5. Action Buttons Reference

1

Mark Paid

Per invoice row

Green button in the Actions column of each invoice row. Triggers markInvoicePaid(recordId, threadId, '', gmailUrl, btn).

Button state changes

  • Immediately: button text changes to "Processing…" and a loading CSS class is added
  • On success: text changes to "Done", background turns green. After 1.5 seconds the row disappears and the list re-renders
  • On failure: text changes to "Failed", background turns red. After 3 seconds button resets to "Mark Paid"
⚠️ Clicking "Mark Paid" manually does not link a bank transaction (the txRecordId parameter is passed as an empty string). The AI-match Approve button is the way to simultaneously link a transaction.
2

✓ Approve

On AI match suggestion row

Appears on the blue suggestion row beneath an invoice when the AI engine finds a matching bank transaction. Triggers approveMatch(recordId, threadId, txRecordId, gmailUrl, btn, rowIdx).

This hides the suggestion row, then calls markInvoicePaid() with the matched transaction's record ID so that the transaction link is also written to Airtable. This is the preferred way to mark an invoice paid when a match exists.

3

✗ Reject

On AI match suggestion row

Appears next to the Approve button on a suggestion row. Triggers rejectMatch(recordId, rowIdx).

Immediately hides the suggestion row in the UI, then writes true to the matchRejected field (fldSn94PRMyScVZA7) in Airtable via a PATCH request. Because matchRejected is now true, the same transaction will not be suggested again for this invoice on future renders.

ℹ️ Rejecting a match does not mark the invoice as paid. It simply prevents that particular transaction from being suggested again. You can still use the "Mark Paid" button separately.
4

Inline Edit (type straight into the table)

Amount, Payee, Description, Ref, Business

Not a button. The table works like a spreadsheet: every Payee, Description, Ref, Amount and Business cell is already an input box, whether it holds a value or not. There is no "click to edit" step. Saving is handled by saveCellInput(), which sends one PATCH per cell to Airtable.

When each cell saves

  • Amount and Business save as soon as the value changes.
  • Payee, Description and Ref save when you click away, so you can keep typing without a save on every keystroke.
  • Enter saves the box you are in.
  • Escape puts the last saved value back and leaves the box.

Rules the save applies

  • A cell you have not actually changed is skipped, so no pointless writes hit Airtable.
  • An amount that is not a number, or is below zero, is rejected and the box is cleared.
  • An empty amount box clears the Amount field in Airtable rather than writing zero.
  • A saved amount is redisplayed to two decimal places, so 40 becomes 40.00.
  • After a save the four KPI cards at the top recalculate on their own. The table is not rebuilt, so you do not lose your place.

Visual feedback after save

The cell flashes green (inv-flash-ok) if the Airtable PATCH succeeded, or red (inv-flash-fail) if it failed. The flash lasts 1.5 seconds.

5

Refresh from Gmail

Header link

A text link in the "Last refreshed" bar that triggers triggerGmailInvoiceSync(). It fires a no-cors request to the Apps Script web app with ?action=sync, then waits 3 seconds before calling fetchInvoicesFromAirtable() to pull in any newly synced records.

Link text changes to "Syncing…" during the wait, then reverts to "Refresh from Gmail" after 4 seconds.

This is the safe link. It adds invoices and nothing else.

It reads the newest 50 threads in the label only. An older invoice email is skipped without any message. Section 3 explains how to lift one back into range.

The Refresh button on the sync bar at the top of the tab does this same job plus an Airtable re-read, so either one is fine. If a Mark Paid ever appears to do nothing, open the sync bar's checks drawer — that is where the failure is reported. Section 3 covers the bar.

6

Reconcile with Gmail

Header link, changes many records at once

The second text link in the "Last refreshed" bar, triggering triggerGmailInvoiceReconcile(). It rewrites the status of every invoice in the table to agree with the Gmail label, in both directions.

⚠️ This is the only control on the page that can undo a payment record. Read section 6 in full before using it.
7

Row tick boxes and the bulk bar

Acts on several invoices at once

Ticking one or more rows opens a green bar above the table with three controls: set the Business on all of them, mark all of them Paid, or clear the selection. Section 7 covers what each one writes.

🔄 6. Reconcile with Gmail

"Reconcile with Gmail" treats the Gmail label as the truth and rewrites Airtable to agree with it. That is the whole idea, and it is also why it needs care: if the Gmail label is wrong, reconcile makes Airtable wrong to match.

⚠️ It moves invoices in BOTH directions, and it can touch every invoice in one click. An invoice you marked Paid goes back to Unpaid with its Paid Date wiped if its email is still in "3: to pay". An invoice sitting as Unpaid is set to Paid, with today's date as the Paid Date, if its email is no longer in that label. Nothing asks you to confirm each record. There is no undo button.

Before you click it, a confirm box lists what is about to happen. Click Cancel if you are not sure. When the run finishes an alert shows five numbers: how many "3: to pay" emails were read, how many invoices were checked in Airtable, how many were marked Paid, how many were restored to Unpaid, and how many were skipped (Estimates and the like). The table then reloads from Airtable.

Exactly what it writes
Email in "3: to pay"?Status in AirtableWhat reconcile does
YesAnything other than Unpaid, including PaidSets Status to Unpaid and clears the Paid Date. The invoice reappears in the table.
NoUnpaidSets Status to Paid and sets the Paid Date to today. The invoice drops out of the table.
EitherEstimateSkipped and left alone.
EitherAny record with no Gmail Thread IDSkipped and left alone.
YesAlready UnpaidNothing. It is already correct.
NoAlready PaidNothing. It is already correct.
ℹ️ Reconcile never writes to the Matched Transaction field and never moves a Gmail label. It only changes the invoice Status and the Paid Date. A payment you approved from an AI match keeps its transaction link even if reconcile pushes it back to Unpaid.
When to use it, and when not to
Use Reconcile when
  • The sync health text has said Mismatch for a while and "Refresh from Gmail" has not cleared it.
  • You paid several invoices straight from Gmail, moved the emails to "4: paid" yourself, and the dashboard still lists them.
  • An invoice was marked Paid by mistake and you have already put its email back in "3: to pay".
Do NOT use Reconcile when
  • You only want new invoices pulled in. Use "Refresh from Gmail" instead.
  • You have just clicked Mark Paid. Give the Gmail label move about 5 seconds first, or reconcile may read the old label and pull the invoice back to Unpaid.
  • Your Gmail labels are known to be untidy. Sort the labels first, because reconcile copies whatever they say.
ℹ️ How many emails it reads. Reconcile reads every email in the "3: to pay" label, asking Gmail for them 100 at a time until it has them all. There is one ceiling: 10,000 emails. If the label ever holds more than that, reconcile stops and writes nothing, and you get an error saying it refused to run from a part-read label. That is deliberate. A half-read label would mark every email it missed as Paid, and a wrong Paid Date is far harder to spot than a run that failed. It is still worth checking the "emails read" number in the result against what Gmail shows on the label.

7. Bulk Actions (tick boxes)

Every row has a tick box in the first column, and the table header has one that selects or clears all the rows you can currently see. Tick at least one row and a green bulk bar appears above the table showing how many are selected.

ℹ️ "Select all" only covers the rows visible under the current search and sort. If you have typed something in the Filter box, the hidden rows are not selected. Your selection survives sorting and filtering, so clear it deliberately rather than assuming a filter change dropped it.
1

Set business

Dropdown plus Apply

Pick a business, click Apply, and applyBulkBusiness() writes that business onto every selected invoice. A confirm box names the business and the number of invoices first.

  • Only ACTIVE businesses appear in the dropdown, the same as the per-row dropdowns.
  • Picking the "None" option clears the Business field on all selected invoices.
  • Records go up in batches of 10, which is the Airtable limit for one write. If a batch fails you get a message saying how many of the total went through.
  • The selection clears and the table redraws once it finishes.
2

Mark as Paid (bulk)

Green button in the bulk bar

applyBulkMarkPaid() does the same job as the per-row Mark Paid button, for every selected invoice at once. A confirm box shows the count and the total amount before anything is written.

What it writes, in order

  1. Airtable: Status to Paid and Paid Date to today, in batches of 10 records.
  2. Gmail: for each invoice that saved successfully, the thread is moved from "3: to pay" to "4: paid", one request per invoice.
  3. The paid invoices are dropped from the on-screen list, the selection clears, and the AP Variable subtab badge updates.
⚠️ Bulk Mark as Paid does not link a bank transaction, even where an AI match is showing on screen. If you want the transaction linked, use the ✓ Approve button on that invoice instead. Also check the total in the confirm box: an invoice with a blank Amount counts as zero towards it, so a low total can mean missing amounts rather than a cheap batch.
ℹ️ If some records fail, you get a message saying how many of the total went through. Only the ones that saved have their Gmail label moved, so a failed invoice stays Unpaid in both places rather than going out of step.
3

Clear selection

Right of the bulk bar

Unticks everything and hides the bulk bar. It writes nothing to Airtable.

🤖 8. Matching & Approval Workflow

Every time renderInvoiceTab() runs, it calls matchInvoicesToTransactions(sourceData, allTransactions) to compare each unpaid invoice against all bank transactions loaded in memory. If a match is found, a blue suggestion row appears directly beneath the invoice row in the table.

⚠️ AI matching only runs for invoices that have an Amount entered. Invoices with a blank Amount field are skipped entirely. Enter the amount first to allow matching.
Matching Algorithm (exact rules from code)

For each invoice, the engine searches all transactions in sequence and stops at the first match. A transaction is a match if all three of the following conditions are met:

  1. Amount tolerance: Math.abs(txAmount - invAmount) <= 0.02 — the transaction's Report Amount (fldot7iisZeL3WrdR) must be within ±£0.02 of the invoice amount. Zero-value transactions are skipped.
  2. Date constraint: The transaction date (fldoyQ6Rr9cHp3bgQ) must be on or after the invoice's effective date (Due Date if set, else Email Date). Transactions that predate the invoice are excluded.
  3. Keyword match: Keywords are extracted from the combined invoice payee + description text (stopwords removed, tokens ≥3 characters). The extracted keywords are then searched in the transaction's Vendor (fld0Xr8sboQ0ekJQJ) + Description (fldsbuAJCTsXHug4C) text. A match requires either:
    • At least 2 keyword hits, OR
    • At least 1 keyword hit where that keyword is 5+ characters long
Keyword Stopwords

The following words are always excluded from keyword extraction and never used for matching. They are defined in extractInvKeywords():

invoice • inv • ltd • limited • the • and • for • from • via • payment • estimate • fwd • re • hi • dear • please • find • attached • kind • regards • sir • madame • thank • thanks • your • our • this • that • has • was

Additionally, any token shorter than 3 characters is excluded.

Suggestion Row Anatomy

When a match is found, a blue-highlighted row (inv-match-suggestion) is inserted immediately after the invoice row. It spans all 9 columns and contains:

  • Label: "🤖 AI Match Found:" in blue
  • Transaction details: date • vendor/label • amount (in bold)
  • Two buttons on the right: ✓ Approve and ✗ Reject

The suggestion row is suppressed (not shown) if inv.matchRejected is true in Airtable.

💵 9. Payment Processing

Clicking Mark Paid or ✓ Approve triggers markInvoicePaid(recordId, threadId, txRecordId, gmailUrl, btn). This function performs three sequential API calls:

1

Airtable Update

Always runs

PATCH to https://api.airtable.com/v0/{BASE_ID}/tblkOTKIG2Tyiy9aM/{recordId}

Fields written:

  • fldJ5InUPlY4t7MgP (Status) → "Paid"
  • fld9GqL9RlLWPAymx (Paid Date) → today's date in YYYY-MM-DD format
  • fldpHf5vYCIgj3Scz (Matched Transaction) → [txRecordId], an array holding the transaction's record ID. Only written if a transaction was matched, so the bulk Mark as Paid never sets it.
2

Gmail Label Move

Runs if Gmail Script URL and Thread ID are set

GET request (no-cors mode) to {GMAIL_SCRIPT_URL}?action=markPaid&threadId={threadId}

This instructs the Apps Script to move the Gmail thread from label "3. to pay" to "4: paid". The request uses mode: 'no-cors' so no response is read — failures are logged to console but do not abort the workflow.

⚠️ If the Gmail label move fails (e.g. Apps Script is unreachable), the invoice is still marked as paid in Airtable. The Gmail label can be moved manually, or the next Apps Script sync will detect the status mismatch. Check the browser console for warning messages.
3

Transaction Invoice Link

Runs only if a transaction was matched

PATCH to https://api.airtable.com/v0/{BASE_ID}/tbln0gzhCAorFc3zB/{txRecordId}

Field written:

  • fldT5qfiyt5DTLrp8 (Invoice Data / txInvoiceData) → the Gmail URL of the invoice email

This attaches a link to the invoice email on the bank transaction record, making it easy to pull up supporting documentation during reconciliation.

Post-payment UI update

After all API calls complete:

  1. The invoice is removed from the airtableInvoices in-memory array immediately.
  2. The AP Variable subtab badge is updated via updateInvoicesSidebarBadge().
  3. After a 1.5-second delay (so the "Done" confirmation is visible), renderInvoiceTab() re-renders the full table without the paid invoice.

🗃 10. Airtable Fields Reference

Dashboard Invoices Table (tblkOTKIG2Tyiy9aM) — INV constants
ConstantField IDHuman nameType / Notes
INV.threadIdfld1qMPjybCraA54HGmail Thread IDText — used to locate and move the Gmail thread
INV.msgIdfldnbLSFMemMuLSzPGmail Message IDText — used for deduplication by Apps Script
INV.payeefldBVAMn9vA1by7MNPayeeText — editable inline
INV.descfldT0onwVg9JDJ1svDescriptionText — editable inline (email subject line)
INV.amountfldauZCUSWeIfGryGAmountCurrency (GBP) — editable inline; required for AI matching
INV.emailDatefldEpaivUV4uXW3DPEmail DateDate — fallback date if Due Date is blank
INV.dueDatefldrZ0BrweP0VCVyRDue DateDate — preferred date for sorting and overdue calculation
INV.reffldKq7JbfOIxeu1aiReferenceText — editable inline; shown in monospace
INV.hasAttachmentfldt8sjSwrfzcfwwJHas Attachment?Checkbox — set by Apps Script if email had attachment
INV.hasPdffldSJg8aLjPlD75rzHas PDF?Checkbox — set by Apps Script if attachment was a PDF
INV.gmailUrlfldeFqA4TVNzDEMChGmail URLURL — direct link to open email; falls back to thread-based URL
INV.statusfldJ5InUPlY4t7MgPStatusSingle select — "Unpaid" or "Paid"; filtered on fetch
INV.paidDatefld9GqL9RlLWPAymxPaid DateDate — set to today (YYYY-MM-DD) when marked paid
INV.matchRejectedfldSn94PRMyScVZA7Match RejectedCheckbox — true = suppress AI suggestion for this invoice
INV.isEstimatefld4DNJoLG76I4xvzIs Estimate?Checkbox — shows "Estimate" badge instead of date-based badge
INV.notesfldV2xsw9en67ts0oNotesLong text — free-form notes (not currently displayed in dashboard)
INV.matchedTxfldpHf5vYCIgj3SczMatched TransactionLinked record — link to the matching Transactions record; set on approve
Transactions Table Fields Used for Matching (tbln0gzhCAorFc3zB)
ConstantField IDHuman nameUsed for
F.txReportAmountfldot7iisZeL3WrdRReport AmountAmount comparison — absolute value compared against invoice amount within ±£0.02
F.txDatefldoyQ6Rr9cHp3bgQDateDate constraint — must be on or after invoice effective date
F.txVendorfld0Xr8sboQ0ekJQJVendorKeyword matching — searched for invoice keywords
F.txDescriptionfldsbuAJCTsXHug4CDescriptionKeyword matching — searched for invoice keywords
F.txInvoiceDatafldT5qfiyt5DTLrp8Invoice DataWritten on pay — receives the Gmail URL as a reference link
Airtable Fetch Query

The initial fetch in fetchInvoicesFromAirtable() uses these Airtable API parameters:

  • filterByFormula: {Status}='Unpaid', so only unpaid records are fetched. Paid and Estimate records stay behind in Airtable.
  • sort: Email Date ascending
  • returnFieldsByFieldId: true — fields are keyed by ID, not name
  • Paging: the fetch follows Airtable's offset until every page is read, so a long list is never cut off at 100 records.

🔧 11. Troubleshooting

An invoice email is not appearing in the dashboard
Check the email has the "3. to pay" Gmail label. Wait up to 15 minutes for the Apps Script sync, or click "Refresh from Gmail" in the dashboard header bar to trigger an immediate sync. If the email is an older one, this is the likely cause: the sync reads only the newest 50 threads in the label, and anything below that is skipped silently. Remove the "3: to pay" label from the email in Gmail and re-apply it, which lifts the thread back to the top of the label, then click Refresh from Gmail again. If still missing, open the Apps Script project and check the execution logs for errors. Verify the AIRTABLE_PAT script property has not expired.
Inline edit does not save (cell flashes red)
A red flash means the Airtable PATCH request failed. Check your internet connection. Open the browser developer console (F12 → Console tab) and look for an error message on the failed fetch. Verify the Airtable PAT stored in localStorage._dlr_pat is valid and has not expired. Also confirm the invoice record still exists in Airtable (it may have been deleted).
No AI match suggestions appearing for an invoice
Matching requires: (1) an Amount entered on the invoice, typed straight into the Amount box on the row; (2) at least one bank transaction in the Transactions table with an amount within ±£0.02 and a date on or after the invoice date; (3) sufficient keyword overlap between the invoice and the transaction vendor/description. If you previously rejected a suggestion for this invoice, the matchRejected field in Airtable is set to true — clear it in Airtable to allow suggestions again.
Gmail label not moving to "4: paid" after marking paid
The label move goes via the Apps Script proxy using mode: 'no-cors', so failures are silent in the UI. Check the browser console for a "Gmail mark-paid failed" warning. Verify the Gmail Script URL in js/config.js is the deployed web app URL (not the script editor URL). Check the Apps Script execution logs. As a fallback, manually apply the "4: paid" label and remove "3. to pay" in Gmail.
Sync health shows "Mismatch" between dashboard and Gmail counts
A mismatch means the number of unpaid invoices in Airtable differs from the number of emails carrying the "3: to pay" Gmail label. First check the Gmail figure is not stuck at 100: the count endpoint reads at most 100 threads, so a label at or above 100 always reports 100 and the indicator will say Mismatch for ever. That is a reporting limit, not a sync problem. Otherwise work through it in this order.
  1. Click "Refresh from Gmail" and wait about 5 seconds. This is usually all it takes, and it can only add invoices.
  2. If the numbers still differ, look at Gmail and decide which side is right. Gmail is the side reconcile will copy.
  3. Once the Gmail labels are correct, click "Reconcile with Gmail". This is the tool that clears a stubborn mismatch, because it rewrites the Airtable status to agree with the label in both directions. Read section 6 first: it can move invoices back from Paid to Unpaid.
  4. If it still will not settle, check the Apps Script execution logs for sync errors, and look for emails labelled "3: to pay" that never created an Airtable record.
Invoices I had marked Paid have come back as Unpaid
That is "Reconcile with Gmail" doing its job. It puts an invoice back to Unpaid and clears its Paid Date whenever the email is still sitting in the "3: to pay" label. Either the Gmail label move failed when you marked it paid, or someone re-labelled the email. Fix the Gmail label first (move the thread to "4: paid"), then mark the invoice paid again, or run reconcile once more now the label is right. The Matched Transaction link is untouched by reconcile, so an approved AI match does not need redoing.
Reconcile marked invoices as Paid that I have not paid
Reconcile marks an Unpaid invoice as Paid whenever its email is no longer in "3: to pay". The usual cause is that the email was moved or archived out of the label by hand, so reconcile did exactly what the label told it to. Reconcile reads every email in the label, so a long label is not the cause: if it ever held more than 10,000 emails, reconcile would refuse to run and write nothing at all. Compare the "emails read" figure in the result against the count Gmail shows on the label. To put an invoice back, re-apply the "3: to pay" label to its email and run reconcile again, or edit the Status and Paid Date directly in Airtable.
A bulk action only updated some of the invoices I selected
Both bulk actions send records to Airtable in batches of 10, and each batch can fail on its own. The message at the end tells you how many of the total went through. Re-select the rows that did not change and run it again. For bulk Mark as Paid, only the invoices that saved to Airtable get their Gmail label moved, so a failed one stays Unpaid in both places rather than going out of step. Check the browser console for the failing batch if it keeps happening.
Dashboard shows "Loading…" and invoices never appear
The Airtable fetch requires a valid PAT. Open the developer console and check for a 401 or 403 HTTP error on the Airtable API call. If you see one, click Logout in the sidebar and re-enter your PAT. If the PAT is correct, check that the base ID (appnqjDpqDniH3IRl) and table ID (tblkOTKIG2Tyiy9aM) in js/config.js are correct.
Duplicate invoice records appearing in Airtable
The Apps Script checks the Gmail Message ID (fldnbLSFMemMuLSzP) before creating a new record. Duplicates can appear if the same email was processed by two concurrent sync runs. Delete the duplicate directly in Airtable — look for records with the same Message ID. If duplicates continue to appear, check whether the Apps Script trigger is running more than once per execution or if the de-duplication query in the script is failing.
The "NEW" badge is not clearing after I view the Invoices tab
The NEW badge clears by writing the current timestamp to localStorage.lastSeenInvoiceTime each time renderInvoiceTab() completes. If it is not clearing, verify that localStorage is accessible in your browser (it can be blocked in private/incognito mode or when third-party storage is restricted). The badge will update on the next automatic data refresh.

📚 12. Changelog

VersionDateChanges
2.19 14 Aug 2026 Drift check against the live code. Corrected the navigation: there is no "Accounts Payable Variable" item in the sidebar, the page is reached via Finance → Accounts → the AP Variable subtab, and the unpaid badge sits on that subtab button rather than in the sidebar. Removed the "reconcile reads only the first 100 threads" warning, which is no longer true: reconcile now pages through the whole label and refuses to write anything at all from a part-read label (10,000 ceiling), and its result alert reports five numbers rather than four. Added: a card covering the sync bar at the top of the tab, its Refresh button and its seven checks, and the note that a failed Mark Paid is reported in its drawer; the 50-thread limit on Refresh from Gmail, with the workaround for an older email, in section 3, section 5 and Troubleshooting; the fact that the sync-health Gmail figure caps at 100, so a large label reads Mismatch permanently; the Clear filters button, which empties the search box only and leaves Sort and ticked rows alone.
2.18 10 Aug 2026 Brought back in line with the live code after a drift check. Added: section 6 Reconcile with Gmail, covering that it rewrites invoice status in BOTH directions, exactly what it writes, when to use it instead of Refresh, and the 100-email label limit; section 7 Bulk Actions, covering row tick boxes, select all, bulk Set Business, bulk Mark as Paid and Clear selection. Corrected: the inline-edit card, which described a click-to-edit function that no longer exists, replaced with the always-on spreadsheet cells and their save rules; the table now has 9 columns including the tick box; the row-click-opens-Gmail behaviour is gone and replaced by the 📧 link; the fetch filter is {Status}='Unpaid', not NOT({Status}='Paid'); Matched Transaction is written as [txRecordId]; Estimate added to the status table. Troubleshooting: the Mismatch entry now points at Reconcile, and three entries added for reconcile flipping invoices and for partial bulk failures.
2.1 17 Apr 2026 Full rewrite against live v2.1 source code. Added: filter/search bar documentation; sidebar badge behaviour and lastSeenInvoiceTime localStorage key; sync health indicator; exact AI matching algorithm (±£0.02 tolerance, keyword rules, stopwords list); Estimate badge type; inline editing keyboard shortcuts and cell-flash feedback; button state machine for Mark Paid; row click → Gmail; complete Airtable field ID reference table; Troubleshooting expanded to 8 scenarios; Changelog added.
2.0 12 Apr 2026 Initial SOP for v2.0 Airtable-backed invoice module. Documented Airtable as source of truth, Apps Script sync, AI matching overview, inline editing, approve/reject flow, mark-paid three-step process, and Google Apps Script setup.