/* ===============================
* =============================== */
INCOME_DB_ID: '055ac6e934044befa59a8db1c34a4e86',
'Name', 'Amount', 'From', 'Invoice Reference', 'Date Invoiced', 'Invoice URL',
'Client Code', 'Project Code', 'Create Invoice Document', 'Days Notice', 'Address',
'Tax_ID', 'Id', 'Type Code', 'Currency'
GBP: '1crhh7fZNUvQGQTgm_4NTcVJhEfm0sD58C6PtqYiH4eU',
EUR: '1Qintc9Z0PQGumQjouhN1Gb1GQvBNm9WrMBX6rxHP4FQ',
USD: '1vKOJAWj-zP4EcEI1bJYEx4o8GdR3g85V3n4R1bw3ytY',
CAD: '1hBuPfsp99gWw9Kgj33iQKc_EhClHHif5iX6K-baz0d0'
INVOICES_ROOT: '1-mkKKkUK35VMUoHE1gNDbytPWbbOkvvO'
ADMIN: 'studio@uncharted-limbo.com'
/* ===============================
* SMALL UTILITIES (safe, pure)
* =============================== */
function currencySymbol_(code) {
function safeGet(obj, path, fallback) {
return path.reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj) ?? fallback;
function nonEmptyArray_(arr) {
return Array.isArray(arr) && arr.length > 0;
/* ===============================
* =============================== */
function getDriveFolderByName(rootFolder, folderName) {
const it = rootFolder.getFoldersByName(folderName);
return it.hasNext() ? it.next() : null;
function getDriveFileByName(rootFolder, fileName) {
const it = rootFolder.getFilesByName(fileName);
return it.hasNext() ? it.next() : null;
function moveFileId(fileId, toFolderId) {
const file = DriveApp.getFileById(fileId);
const sourceFolder = file.getParents().hasNext() ? file.getParents().next() : null;
const destinationFolder = DriveApp.getFolderById(toFolderId);
destinationFolder.addFile(file);
if (sourceFolder) sourceFolder.removeFile(file);
/* ===============================
* =============================== */
* Insert rows to the first table in a Doc with [qty, amount, unitprice, description] mapping.
* rowData: [ [qty, amount, unitPrice, description], ... ]
function gdoc_addTableData(doc, rowData, currencySymbol) {
const body = doc.getBody();
const tables = body.getTables();
if (!nonEmptyArray_(tables)) return;
const rows = rowData.length;
for (let r = 0; r < rows; r++) {
const row = table.insertTableRow(1 + r);
const description = row.appendTableCell(String(rowData[r][3] ?? ''));
const quantity = row.appendTableCell(String(rowData[r][0] ?? ''));
const unitPrice = row.appendTableCell(currencySymbol + String(rowData[r][2] ?? ''));
const amount = row.appendTableCell(currencySymbol + String(rowData[r][1] ?? ''));
center[DocumentApp.Attribute.HORIZONTAL_ALIGNMENT] = DocumentApp.HorizontalAlignment.CENTER;
[description, quantity, unitPrice, amount].forEach(c => {
if (c.getNumChildren() > 0) c.getChild(0).asParagraph().setAttributes(center);
// Update borders for the inserted range via Docs API (needs Advanced Google Services: Docs)
const docId = doc.getId();
const tableBodyIndex = body.getChildIndex(table);
const startIndex = Docs.Documents.get(docId).body.content[tableBodyIndex + 1].startIndex;
borderBottom: { dashStyle: 'SOLID', width: { magnitude: 1, unit: 'PT' }, color: { color: { rgbColor: { red: 0 } } } },
borderRight: { dashStyle: 'SOLID', width: { magnitude: 0, unit: 'PT' }, color: { color: {} } },
borderLeft: { dashStyle: 'SOLID', width: { magnitude: 0, unit: 'PT' }, color: { color: {} } }
tableCellLocation: { tableStartLocation: { index: startIndex }, rowIndex: 1 },
columnSpan: rowData[0]?.length || 4
fields: 'borderBottom,borderRight,borderLeft'
Docs.Documents.batchUpdate({ requests }, docId);
function gdoc_addBulletPointsBelow(doc, parentText, bullets) {
const body = doc.getBody();
const paragraphs = body.getParagraphs();
for (let i = 0; i < paragraphs.length; i++) {
if (paragraphs[i].getText() === parentText) {
for (let b = 0; b < bullets.length; b++) {
style[DocumentApp.Attribute.FONT_SIZE] = 12;
style[DocumentApp.Attribute.FOREGROUND_COLOR] = '#000000';
const li = body.insertListItem(i + 1 + b, String(bullets[b] ?? ''));
li.setGlyphType(DocumentApp.GlyphType.BULLET);
/* ===============================
* (reuse across projects)
* =============================== */
/** Resolve a database_id from a block object. */
function resolveDatabaseIdFromBlock_(block) {
if (!block || typeof block !== 'object') return { id: null, reason: 'No block' };
if (t === 'link_to_database' && safeGet(block, ['link_to_database', 'database_id'], null)) {
return { id: block.link_to_database.database_id, reason: 'link_to_database.database_id' };
if (t === 'child_database') {
return { id: block.id, reason: 'child_database block id (validate recommended)' };
return { id: null, reason: 'Block is type ' + t + ' (not a database block)' };
/** Optional: use your wrapper to confirm this is a database. */
function validateDatabaseId_(databaseId) {
const db = ULC_NotionAPI.retrieveDatabase(databaseId);
return db && db.object === 'database';
* BFS scan page content (and nested children) to find up to `limit` database_ids.
* Requires: ULC_NotionAPI.getNotionPageContent, ULC_NotionAPI.getNotionBlockChildren.
function resolveDatabaseIdsFromPage_(pageId, limit) {
const queue = [{ id: pageId, kind: 'page' }];
while (queue.length && found.length < limit) {
const item = queue.shift();
resp = (item.kind === 'page')
? ULC_NotionAPI.getNotionPageContent(item.id)
: ULC_NotionAPI.getNotionBlockChildren(item.id);
throw new Error('Failed to fetch children for ' + item.kind + ' ' + item.id + ': ' + e);
const results = resp && resp.results;
if (!Array.isArray(results)) {
throw new Error('Invalid response listing children for ' + item.kind + ' ' + item.id + ': ' + JSON.stringify(resp));
for (let i = 0; i < results.length && found.length < limit; i++) {
const block = results[i];
if (block.type === 'child_database') {
found.push({ database_id: block.id, via: 'child_database', block });
if (block.type === 'link_to_database' && safeGet(block, ['link_to_database', 'database_id'], null)) {
found.push({ database_id: block.link_to_database.database_id, via: 'link_to_database', block });
if (block.has_children) queue.push({ id: block.id, kind: 'block' });
// paginate children if necessary (assuming your wrapper can accept a cursor)
while (resp.has_more && found.length < limit) {
resp = ULC_NotionAPI.getNotionBlockChildren(resp.next_cursor);
throw new Error('Pagination failed fetching children: ' + e2);
const res2 = resp && resp.results;
if (!Array.isArray(res2)) break;
for (let j = 0; j < res2.length && found.length < limit; j++) {
if (b2.type === 'child_database') {
found.push({ database_id: b2.id, via: 'child_database', block: b2 });
if (b2.type === 'link_to_database' && safeGet(b2, ['link_to_database', 'database_id'], null)) {
found.push({ database_id: b2.link_to_database.database_id, via: 'link_to_database', block: b2 });
if (b2.has_children) queue.push({ id: b2.id, kind: 'block' });
if (found.length === 0) {
throw new Error('No database blocks found in page ' + pageId + '.');
return found.slice(0, limit);
* Get [propertyNames, values, rowCounts] for "Scope of Work" and "Agreed Fee Structure".
* Returns: [ [propNamesScope, propNamesFees], [valuesScope, valuesFees], [rowsScope, rowsFees] ]
function getNotionInvoiceTables(databaseId, index) {
const data = ULC_NotionAPI.notionData([], databaseId);
if (!nonEmptyArray_(data)) throw new Error('Income DB returned no rows.');
const pageId = data[index]?.id;
if (!pageId) throw new Error('Invalid page index ' + index);
const resolved = resolveDatabaseIdsFromPage_(pageId, 2);
if (resolved.length < 2) throw new Error('Could not find 2 database blocks on page ' + pageId);
const scopeDBId = resolved[0].database_id;
const feesDBId = resolved[1].database_id;
if (!validateDatabaseId_(scopeDBId) || !validateDatabaseId_(feesDBId)) {
throw new Error('Resolved database IDs failed validation: ' + [scopeDBId, feesDBId].join(', '));
const scopeDB = ULC_NotionAPI.notionData([], scopeDBId);
const feesDB = ULC_NotionAPI.notionData([], feesDBId);
const databases = [scopeDB, feesDB];
const propertyNames = [];
for (let d = 0; d < databases.length; d++) {
const cur = databases[d];
if (!nonEmptyArray_(cur)) {
const propsFirst = cur[0].properties || {};
const curPropNames = Object.keys(propsFirst);
const curValuesFlat = [];
for (let i = 0; i < cur.length; i++) {
const props = cur[i].properties || {};
curValuesFlat.push(ULC_NotionAPI.getNotionProperties(prop, prop.type));
propertyNames.push(curPropNames);
// If you prefer your ULC_Sheets helper, keep it. Otherwise chunk deterministically:
values.push(ULC_Sheets.splitToChunks(curValuesFlat, cur.length));
rowCounts.push(cur.length);
return [propertyNames, values, rowCounts];
/* ===============================
* =============================== */
function createInvoiceDocument() {
const dbId = CONFIG.NOTION.INCOME_DB_ID;
const data = ULC_NotionAPI.notionData(CONFIG.NOTION.INCOME_FIELDS, dbId);
if (!nonEmptyArray_(data)) return;
// Build quick lookup caches of existing client/project folders
const invoicesRoot = DriveApp.getFolderById(CONFIG.DRIVE.INVOICES_ROOT);
const clientFolders = [];
const projectFolders = [];
const clientIter = invoicesRoot.getFolders();
while (clientIter.hasNext()) {
const cf = clientIter.next();
clientFolders.push(cf.getName());
const projIter = cf.getFolders();
while (projIter.hasNext()) projectFolders.push(projIter.next().getName());
for (let i = 0; i < data.length; i++) {
const props = data[i].properties;
const invoiceUrl = safeGet(props, ['Invoice URL', 'url'], null);
const createDoc = safeGet(props, ['Create Invoice Document', 'checkbox'], 0);
if (invoiceUrl !== null || createDoc !== 1) continue;
const client = safeGet(props, ['From', 'rollup', 'array', 0, 'select', 'name'], null);
const clientCode = safeGet(props, ['Client Code', 'rollup', 'array', 0, 'select', 'name'], null);
const projectCode = safeGet(props, ['Project Code', 'rollup', 'array', 0, 'select', 'name'], null);
const invoiceName = safeGet(props, ['Name', 'title', 0, 'plain_text'], null);
const dateInvoiced= safeGet(props, ['Date Invoiced', 'date', 'start'], null);
const invoiceRef = safeGet(props, ['Invoice Reference', 'formula', 'string'], null);
const amount = safeGet(props, ['Amount', 'number'], null);
const notice = safeGet(props, ['Days Notice', 'number'], null);
const address = safeGet(props, ['Address', 'rollup', 'array', 0, 'rich_text', 0, 'plain_text'], '');
const taxId = safeGet(props, ['Tax_ID', 'rollup', 'array', 0, 'rich_text', 0, 'plain_text'], '');
const idStr = safeGet(props, ['Id', 'rich_text', 0, 'plain_text'], null);
const typeCode = safeGet(props, ['Type Code', 'rich_text', 0, 'plain_text'], null);
const currency = safeGet(props, ['Currency', 'select', 'name'], 'GBP');
const required = [client, clientCode, projectCode, invoiceName, dateInvoiced, invoiceRef, amount, notice];
if (!required.every(v => v !== null)) {
const names = ['Client', 'Client Code', 'Project Code', 'Invoice Name', 'Date Invoiced', 'Invoice Ref', 'Amount', 'Notice'];
const missing = names.filter((_, idx) => required[idx] === null).map(n => ` * ${n}`).join('\n');
'AppScript error - Invoice for : ' + (invoiceName || '(missing name)'),
`Invoice could not be created because some Notion properties were empty:\n${missing}`
Logger.log(`Preparing invoice doc: ${invoiceName}, client: ${client}, project: ${projectCode}`);
const pageId = data[i].id;
const [propNames, values, rowCounts] = getNotionInvoiceTables(dbId, i);
// Folders: ensure client/project exist
let clientFolder = clientFolders.includes(client)
? getDriveFolderByName(invoicesRoot, client)
: invoicesRoot.createFolder(client);
if (!clientFolders.includes(client)) clientFolders.push(client);
let projectFolder = projectFolders.includes(projectCode)
? getDriveFolderByName(clientFolder, projectCode)
: clientFolder.createFolder(projectCode);
if (!projectFolders.includes(projectCode)) projectFolders.push(projectCode);
const docName = dateInvoiced.replace(/-/g, '').slice(2) + '_' + clientCode + '_' + invoiceName;
if (getDriveFileByName(projectFolder, docName)) {
'Invoice for : ' + invoiceName,
'An Invoice document already exists here: ' + projectFolder.getUrl() +
' ,but it is not connected to Notion Income DB. Document name: ' + docName
// Pick template per currency
const templateId = CONFIG.DOC_TEMPLATES[currency] || CONFIG.DOC_TEMPLATES.GBP;
const template = DriveApp.getFileById(templateId);
const copy = template.makeCopy(docName, projectFolder);
const doc = DocumentApp.openById(copy.getId());
const body = doc.getBody();
const symbol = currencySymbol_(currency);
const vatAmount = amount * CONFIG.INVOICE.VAT_RATE;
const totalAmount = amount + vatAmount;
body.replaceText('{{Invoice Reference}}', invoiceRef);
body.replaceText('{{Date Invoiced}}', dateInvoiced);
body.replaceText('{{Client}}', client);
body.replaceText('{{Amount}}', symbol + amount);
body.replaceText('{{VAT}}', symbol + vatAmount);
body.replaceText('{{TotalAmount}}', symbol + totalAmount);
body.replaceText('{{Notice}}', String(notice));
body.replaceText('{{Currency}}', currency);
body.replaceText('{{Address}}', address);
body.replaceText('{{Tax_ID}}', taxId);
const bulletpoints = ULC_Sheets.flatten(values[0]); // scope table values flat → list of strings
gdoc_addBulletPointsBelow(doc, 'Scope of Work', bulletpoints);
// Agreed Fee Structure table
const tableData = values[1]; // 2nd table
gdoc_addTableData(doc, tableData, symbol);
Logger.log('Replaced placeholders with Notion values');
// Export to PDF and move into same folder
const pdfBlob = doc.getAs('application/pdf').setName(doc.getName() + '.pdf');
const pdfFile = DriveApp.createFile(pdfBlob);
moveFileId(pdfFile.getId(), projectFolder.getId());
const pdfUrl = pdfFile.getUrl().toString();
// Write back Invoice URL to Notion
ULC_NotionAPI.setNotionPropertyValue(pageId, {
properties: { 'Invoice URL': { url: pdfUrl } }
'Invoice for : ' + invoiceName,
`A new Invoice Document was created for client: ${client} and project: ${projectCode}\nLink: ${pdfUrl}`
Logger.log('Invoice created & emailed: ' + pdfUrl);
// Currency conversion to GBP can be added here later if needed.
Logger.log('Error on row ' + i + ': ' + (err && err.stack ? err.stack : err));
// Consider notifying admin once per run if critical.