tblkOTKIG2Tyiy9aMappnqjDpqDniH3IRllastSeenInvoiceTime — tracks new-invoice badgeThe 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:
lastSeenInvoiceTime, which controls the "NEW" badge on the AP Variable subtab — this is purely cosmetic.
renderInvoiceTab() runs immediately to display the current invoice list.5 • 2 new) when invoices have arrived since you last viewed the tab. The badge clears to grey once you open the tab.
The badge is controlled by updateInvoicesSidebarBadge() and compares each invoice's Airtable createdTime against the lastSeenInvoiceTime stored in localStorage.
| Badge appearance | Meaning |
|---|---|
| 5 • 2 new | 5 unpaid invoices total; 2 arrived since you last viewed the tab |
| 3 | 3 unpaid invoices; none are new since last view |
| (blank) | No unpaid invoices — all are paid or none exist |
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.
The four cards are always calculated from all unpaid invoices, regardless of any active filter.
Total count of invoices where Status is not "Paid". Subtitle: "Synced from Gmail '3. to pay'"
Sum of all unpaid invoices that have an amount entered. Shown in red. Subtitle shows how many of the total have amounts.
Count of invoices where the Amount field is still blank. Subtitle suggests clicking to enter amounts. Shown in grey if zero.
Count of unpaid invoices where the effective date (Due Date if set, otherwise Email Date) is before today. Shown in red.
invSortBy)Located above the table. Changing the selection immediately re-renders the list via renderInvoiceTab(). Sort is applied after the text filter.
| Option value | Label | Sort logic |
|---|---|---|
date | Date (oldest first) | Effective date ascending — default |
date-desc | Date (newest first) | Effective date descending |
amount | Amount (high→low) | Amount descending; nulls treated as 0 |
amount-asc | Amount (low→high) | Amount ascending; nulls treated as 0 |
payee | Payee (A→Z) | Payee name locale-compare ascending |
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:
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.
| # | Column header | Description |
|---|---|---|
| 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. |
| 3 | Due / Date | Effective 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). |
| 4 | Payee | Supplier name in an always-on text box. Empty boxes show the placeholder "Payee…". |
| 5 | Description | Invoice description or email subject, always-on text box. Placeholder "Description…". |
| 6 | Ref | Payment reference, always-on text box in monospace. Placeholder "Ref…". |
| 7 | Amount | Invoice amount, always-on box, right-aligned, shown to two decimal places. Placeholder "0.00". |
| 8 | Business | Which 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. |
Each invoice row shows a coloured badge beneath the date. The badge is determined by the invoice's effective date and the isEstimate flag:
| Badge | Condition |
|---|---|
| Estimate | isEstimate field is true — shown regardless of date |
| Overdue | Effective date is before today and not an estimate |
| Due soon | Effective date is within 7 days from today |
| Upcoming | Effective date is more than 7 days away |
| NEW | Invoice was created in Airtable after you last viewed the tab (compared via lastSeenInvoiceTime). Can appear alongside another badge. |
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:
The bar above the table holds two links that do very different jobs. Read this before clicking either.
| Link | What it does | Can 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.
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.
The Status field (fldJ5InUPlY4t7MgP) in Airtable drives what you see:
| Status | Meaning | Visible on dashboard? |
|---|---|---|
| Unpaid | Default state when created by Apps Script. Invoice needs attention. | Yes, this is the only status the table shows |
| Paid | Set 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 |
| Estimate | Set 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 |
Green button in the Actions column of each invoice row. Triggers markInvoicePaid(recordId, threadId, '', gmailUrl, btn).
loading CSS class is addedtxRecordId parameter is passed as an empty string). The AI-match Approve button is the way to simultaneously link a transaction.
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.
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.
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.
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.
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.
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.
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.
"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.
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.
| Email in "3: to pay"? | Status in Airtable | What reconcile does |
|---|---|---|
| Yes | Anything other than Unpaid, including Paid | Sets Status to Unpaid and clears the Paid Date. The invoice reappears in the table. |
| No | Unpaid | Sets Status to Paid and sets the Paid Date to today. The invoice drops out of the table. |
| Either | Estimate | Skipped and left alone. |
| Either | Any record with no Gmail Thread ID | Skipped and left alone. |
| Yes | Already Unpaid | Nothing. It is already correct. |
| No | Already Paid | Nothing. It is already correct. |
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.
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.
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.
Unticks everything and hides the bulk bar. It writes nothing to Airtable.
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.
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:
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.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.fld0Xr8sboQ0ekJQJ) + Description (fldsbuAJCTsXHug4C) text. A match requires either:
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.
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:
The suggestion row is suppressed (not shown) if inv.matchRejected is true in Airtable.
Clicking Mark Paid or ✓ Approve triggers markInvoicePaid(recordId, threadId, txRecordId, gmailUrl, btn). This function performs three sequential API calls:
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 formatfldpHf5vYCIgj3Scz (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.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.
PATCH to https://api.airtable.com/v0/{BASE_ID}/tbln0gzhCAorFc3zB/{txRecordId}
Field written:
fldT5qfiyt5DTLrp8 (Invoice Data / txInvoiceData) → the Gmail URL of the invoice emailThis attaches a link to the invoice email on the bank transaction record, making it easy to pull up supporting documentation during reconciliation.
After all API calls complete:
airtableInvoices in-memory array immediately.updateInvoicesSidebarBadge().renderInvoiceTab() re-renders the full table without the paid invoice.tblkOTKIG2Tyiy9aM) — INV constants| Constant | Field ID | Human name | Type / Notes |
|---|---|---|---|
INV.threadId | fld1qMPjybCraA54H | Gmail Thread ID | Text — used to locate and move the Gmail thread |
INV.msgId | fldnbLSFMemMuLSzP | Gmail Message ID | Text — used for deduplication by Apps Script |
INV.payee | fldBVAMn9vA1by7MN | Payee | Text — editable inline |
INV.desc | fldT0onwVg9JDJ1sv | Description | Text — editable inline (email subject line) |
INV.amount | fldauZCUSWeIfGryG | Amount | Currency (GBP) — editable inline; required for AI matching |
INV.emailDate | fldEpaivUV4uXW3DP | Email Date | Date — fallback date if Due Date is blank |
INV.dueDate | fldrZ0BrweP0VCVyR | Due Date | Date — preferred date for sorting and overdue calculation |
INV.ref | fldKq7JbfOIxeu1ai | Reference | Text — editable inline; shown in monospace |
INV.hasAttachment | fldt8sjSwrfzcfwwJ | Has Attachment? | Checkbox — set by Apps Script if email had attachment |
INV.hasPdf | fldSJg8aLjPlD75rz | Has PDF? | Checkbox — set by Apps Script if attachment was a PDF |
INV.gmailUrl | fldeFqA4TVNzDEMCh | Gmail URL | URL — direct link to open email; falls back to thread-based URL |
INV.status | fldJ5InUPlY4t7MgP | Status | Single select — "Unpaid" or "Paid"; filtered on fetch |
INV.paidDate | fld9GqL9RlLWPAymx | Paid Date | Date — set to today (YYYY-MM-DD) when marked paid |
INV.matchRejected | fldSn94PRMyScVZA7 | Match Rejected | Checkbox — true = suppress AI suggestion for this invoice |
INV.isEstimate | fld4DNJoLG76I4xvz | Is Estimate? | Checkbox — shows "Estimate" badge instead of date-based badge |
INV.notes | fldV2xsw9en67ts0o | Notes | Long text — free-form notes (not currently displayed in dashboard) |
INV.matchedTx | fldpHf5vYCIgj3Scz | Matched Transaction | Linked record — link to the matching Transactions record; set on approve |
tbln0gzhCAorFc3zB)| Constant | Field ID | Human name | Used for |
|---|---|---|---|
F.txReportAmount | fldot7iisZeL3WrdR | Report Amount | Amount comparison — absolute value compared against invoice amount within ±£0.02 |
F.txDate | fldoyQ6Rr9cHp3bgQ | Date | Date constraint — must be on or after invoice effective date |
F.txVendor | fld0Xr8sboQ0ekJQJ | Vendor | Keyword matching — searched for invoice keywords |
F.txDescription | fldsbuAJCTsXHug4C | Description | Keyword matching — searched for invoice keywords |
F.txInvoiceData | fldT5qfiyt5DTLrp8 | Invoice Data | Written on pay — receives the Gmail URL as a reference link |
The initial fetch in fetchInvoicesFromAirtable() uses these Airtable API parameters:
{Status}='Unpaid', so only unpaid records are fetched. Paid and Estimate records stay behind in Airtable.true — fields are keyed by ID, not nameoffset until every page is read, so a long list is never cut off at 100 records.localStorage._dlr_pat is valid and has not expired. Also confirm the invoice record still exists in Airtable (it may have been deleted).
matchRejected field in Airtable is set to true — clear it in Airtable to allow suggestions again.
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.
appnqjDpqDniH3IRl) and table ID (tblkOTKIG2Tyiy9aM) in js/config.js are correct.
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.
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.
| Version | Date | Changes |
|---|---|---|
| 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. |