Build a Free Invoicing App with Google Sheets and Send PDF Invoices by Email
Create a new blank Google Sheet. This Sheet will act as the database for the invoicing app. You do not need to create any tabs or columns manually, the setup sc
Create a blank Google Sheet
Create a new blank Google Sheet. This Sheet will act as the database for the invoicing app. You do not need to create any tabs or columns manually, the setup script creates them automatically.
Open the Apps Script editor
In your Google Sheet, go to Extensions then Apps Script. Delete any starter code inside Code.g
Add the backend code, Code.gs
Copy the complete server-side code below and paste it into Code.gs. This handles setup, invoices, clients, products, PDF generation, email functionality, Google Drive, and the connection to your Sheet.
/*************************************************************************
* COMPLETO INVOICING — Code.gs (v3.1)
* Server-side logic. Uses the active (container-bound) Google Sheet
* as the database. Setup auto-runs once; schema migrations run safely
* on existing installs.
*
* v3.1 changes:
* - PDF footer wording: "Issued with" → "Made by"
* - Added PLATFORM_LOGO_LIGHT_URL (light wordmark, used by the sidebar)
* - Added optional platform_logo_light_url setting + bootstrap exposure
*************************************************************************/
const APP_NAME = 'Completo Invoicing';
const PLATFORM_SITE = 'https://www.completo.cloud';
const PLATFORM_LOGO_URL = 'https://www.completo.cloud/_next/image/?url=%2Fbrand%2Fcompleto-wordmark.png&w=384&q=75';
const PLATFORM_LOGO_FALLBACK = 'https://www.completo.cloud/brand/completo-wordmark.png';
const PLATFORM_LOGO_LIGHT_URL = 'https://www.completo.cloud/_next/image/?url=%2Fbrand%2Fcompleto-wordmark-light.png&w=384&q=75';
const SCHEMA_VERSION = 3;
const PDF_ROOT_FOLDER = 'Completo Invoicing';
const PDF_SUB_FOLDER = 'PDFs';
const ACCENT = '#2B2F3A'; // graphite
const STATUSES = ['Draft', 'Sent', 'Paid', 'Overdue'];
const CACHE_TTL = 21600; // 6 h
const HEADERS = {
Settings: ['Key', 'Value'],
Clients: ['ID', 'Name', 'Company', 'Email', 'Phone', 'Address', 'TaxID', 'CreatedAt'],
Products: ['ID', 'Name', 'Description', 'UnitPrice', 'Unit', 'Active', 'CreatedAt'],
Invoices: ['ID', 'Number', 'ClientID', 'IssueDate', 'DueDate', 'Status', 'TaxRate', 'Subtotal', 'Tax', 'Total', 'Notes', 'PdfFileId', 'SentAt', 'PaidAt', 'CreatedAt', 'UpdatedAt', 'PdfAt'],
InvoiceItems: ['ID', 'InvoiceID', 'Description', 'Qty', 'UnitPrice', 'Amount', 'SortOrder', 'ProductID'],
EmailTemplates: ['ID', 'Name', 'Subject', 'Body', 'CreatedAt']
};
const TEXT_COL_REGEX = /(ID|Date|At|Number|Phone|Key|Value|Email)$/;
const SETTING_KEYS = [
'business_name', 'business_address', 'business_phone', 'business_email', 'business_tax_id',
'logo_file_id', 'payment_details', 'currency_symbol', 'tax_rate_default',
'payment_terms_days', 'invoice_prefix', 'default_notes', 'platform_logo_url',
'platform_logo_light_url', 'email_cc_self'
];
const SETTING_DEFAULTS = {
business_name: 'Your Business Name',
business_address: '123 Main Street\nSuite 400\nSpringfield, ST 12345',
business_phone: '+1 555 010 0100',
business_email: '',
business_tax_id: 'TAX-000000',
logo_file_id: '',
payment_details: 'Bank: Example Bank\nAccount name: Your Business Name\nIBAN: XX00 0000 0000 0000 0000\nSWIFT/BIC: EXAMPLXX\nPlease quote the invoice number as payment reference.',
currency_symbol: '$',
tax_rate_default: '0',
payment_terms_days: '30',
invoice_prefix: 'INV-',
default_notes: '',
platform_logo_url: PLATFORM_LOGO_URL,
platform_logo_light_url: PLATFORM_LOGO_LIGHT_URL,
email_cc_self: 'false',
settings_updated_at: ''
};
/* ======================================================================
* WEB APP ENTRY
* ==================================================================== */
function doGet() {
ensureSetup();
return HtmlService.createHtmlOutputFromFile('index')
.setTitle(APP_NAME)
.addMetaTag('viewport', 'width=device-width, initial-scale=1')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
/** Run ONCE from the editor: touches every service so all permissions are granted in a single prompt. */
function authorizeOnce() {
SpreadsheetApp.getActiveSpreadsheet().getName();
DriveApp.getRootFolder().getName();
GmailApp.getAliases();
UrlFetchApp.fetch('https://www.google.com', { muteHttpExceptions: true });
CacheService.getScriptCache().get('ping');
LockService.getScriptLock().hasLock();
Session.getEffectiveUser().getEmail();
ensureSetup();
return 'All permissions granted. Now deploy a NEW version of the web app.';
}
function setup() { ensureSetup(); return 'Setup complete.'; }
/* ======================================================================
* SETUP + MIGRATION
* ==================================================================== */
function ensureSetup() {
const cache = CacheService.getScriptCache();
if (cache.get('schema_v') === String(SCHEMA_VERSION)) return;
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
if (!isSetupDone_()) setup_();
migrate_();
cache.put('schema_v', String(SCHEMA_VERSION), CACHE_TTL);
} finally {
lock.releaseLock();
}
}
function isSetupDone_() {
const sh = ss_().getSheetByName('Settings');
if (!sh || sh.getLastRow() < 2) return false;
const vals = sh.getRange(1, 1, sh.getLastRow(), 2).getValues();
return vals.some(r => String(r[0]) === 'setup_done' && String(r[1]) === 'true');
}
function writeHeaders_(sh, headers) {
sh.getRange(1, 1, 1, headers.length).setValues([headers])
.setFontWeight('bold').setBackground(ACCENT).setFontColor('#FFFFFF');
sh.setFrozenRows(1);
headers.forEach((h, i) => {
if (TEXT_COL_REGEX.test(h)) sh.getRange(2, i + 1, sh.getMaxRows() - 1, 1).setNumberFormat('@');
sh.setColumnWidth(i + 1, /Address|Body|Notes|Description|payment/i.test(h) ? 320 : 150);
});
}
function seedProducts_(sh) {
const now = now_();
sh.getRange(2, 1, 4, 7).setValues([
[Utilities.getUuid(), 'Consulting (hourly)', 'Strategy and advisory session', 150, 'hour', 'TRUE', now],
[Utilities.getUuid(), 'Website design', 'Custom responsive website design', 2400, 'project', 'TRUE', now],
[Utilities.getUuid(), 'Monthly retainer', 'Ongoing support, updates & maintenance', 400, 'month', 'TRUE', now],
[Utilities.getUuid(), 'Landing page copywriting', 'Conversion-focused copy for one page', 150, 'page', 'TRUE', now]
]);
}
function setup_() {
const ss = ss_();
Object.keys(HEADERS).forEach(name => {
let sh = ss.getSheetByName(name);
if (!sh) sh = ss.insertSheet(name);
sh.clear();
writeHeaders_(sh, HEADERS[name]);
});
ss.getSheets().forEach(s => {
if (!HEADERS[s.getName()] && s.getLastRow() === 0 && ss.getSheets().length > 1) ss.deleteSheet(s);
});
const defaults = Object.assign({}, SETTING_DEFAULTS, {
business_email: Session.getEffectiveUser().getEmail() || 'hello@yourbusiness.com'
});
const settings = Object.keys(defaults).map(k => [k, defaults[k]]);
settings.push(['next_invoice_number', '5']);
settings.push(['setup_done', 'false']);
const shS = ss.getSheetByName('Settings');
shS.getRange(2, 1, settings.length, 2).setValues(settings);
const now = now_();
const c1 = Utilities.getUuid(), c2 = Utilities.getUuid(), c3 = Utilities.getUuid();
ss.getSheetByName('Clients').getRange(2, 1, 3, 8).setValues([
[c1, 'Jane Cooper', 'Acme Corporation', 'jane.cooper@example.com', '+1 555 234 5678', '742 Evergreen Terrace\nSpringfield, ST 12345', 'US-ACME-2201', now],
[c2, 'Marcus Lee', 'Northwind Traders', 'marcus@northwind.example.com', '+1 555 876 5432', '1 Harbour Way\nSeattle, WA 98101', 'US-NW-7781', now],
[c3, 'Priya Patel', 'Bluebird Studio', 'priya@bluebird.example.com', '+1 555 345 6789', '88 Canal Street\nNew York, NY 10013', '', now]
]);
seedProducts_(ss.getSheetByName('Products'));
const today = today_();
const i1 = Utilities.getUuid(), i2 = Utilities.getUuid(), i3 = Utilities.getUuid(), i4 = Utilities.getUuid();
ss.getSheetByName('Invoices').getRange(2, 1, 4, 17).setValues([
[i1, 'INV-0001', c1, addDays_(today, -40), addDays_(today, -10), 'Paid', 0, 2400, 0, 2400, 'Thank you for your business.', '', addDays_(today, -40), addDays_(today, -12), now, now, ''],
[i2, 'INV-0002', c2, addDays_(today, -5), addDays_(today, 25), 'Sent', 10, 1850, 185, 2035, 'Payment due within 30 days.', '', addDays_(today, -5), '', now, now, ''],
[i3, 'INV-0003', c3, addDays_(today, -45), addDays_(today, -15), 'Overdue', 0, 960, 0, 960, '', '', addDays_(today, -45), '', now, now, ''],
[i4, 'INV-0004', c1, today, addDays_(today, 30), 'Draft', 0, 1200, 0, 1200, 'Draft — pending review.', '', '', '', now, now, '']
]);
ss.getSheetByName('InvoiceItems').getRange(2, 1, 6, 8).setValues([
[Utilities.getUuid(), i1, 'Website redesign — discovery & wireframes', 1, 1500, 1500, 1, ''],
[Utilities.getUuid(), i1, 'UI design revisions', 6, 150, 900, 2, ''],
[Utilities.getUuid(), i2, 'Monthly retainer — content & SEO', 1, 1250, 1250, 1, ''],
[Utilities.getUuid(), i2, 'Landing page copywriting', 4, 150, 600, 2, ''],
[Utilities.getUuid(), i3, 'Brand identity consultation', 8, 120, 960, 1, ''],
[Utilities.getUuid(), i4, 'Support & maintenance (Q1)', 3, 400, 1200, 1, '']
]);
ss.getSheetByName('EmailTemplates').getRange(2, 1, 2, 5).setValues([
[Utilities.getUuid(), 'Standard invoice',
'Invoice {{invoice_number}} from {{business_name}}',
'Hi {{client_name}},\n\nPlease find attached invoice {{invoice_number}} for {{total}}, due on {{due_date}}.\n\nPayment details are included at the bottom of the invoice. Let me know if you have any questions.\n\nThank you,\n{{business_name}}', now],
[Utilities.getUuid(), 'Payment reminder',
'Reminder: invoice {{invoice_number}} is due {{due_date}}',
'Hi {{client_name}},\n\nThis is a friendly reminder that invoice {{invoice_number}} for {{total}} was due on {{due_date}}. A copy is attached for your convenience.\n\nIf you have already sent payment, please disregard this message.\n\nBest regards,\n{{business_name}}', now]
]);
const keys = shS.getRange(2, 1, settings.length, 1).getValues().map(r => String(r[0]));
shS.getRange(keys.indexOf('setup_done') + 2, 2).setValue('true');
META_ = {};
}
/** Upgrades older installs: missing sheets, columns, settings; moves platform logo from "auto" to the fixed URL. */
function migrate_() {
const ss = ss_();
Object.keys(HEADERS).forEach(name => {
const headers = HEADERS[name];
let sh = ss.getSheetByName(name);
if (!sh) {
sh = ss.insertSheet(name);
writeHeaders_(sh, headers);
if (name === 'Products') seedProducts_(sh);
return;
}
const lastCol = Math.max(sh.getLastColumn(), 1);
const existing = sh.getRange(1, 1, 1, lastCol).getValues()[0].map(String);
const missing = headers.filter(h => existing.indexOf(h) < 0);
if (!missing.length) return;
const start = existing.filter(Boolean).length + 1;
sh.getRange(1, start, 1, missing.length).setValues([missing])
.setFontWeight('bold').setBackground(ACCENT).setFontColor('#FFFFFF');
missing.forEach((h, i) => {
if (TEXT_COL_REGEX.test(h)) sh.getRange(2, start + i, sh.getMaxRows() - 1, 1).setNumberFormat('@');
});
});
META_ = {};
const shS = getSheet_('Settings');
const rows = readTable_('Settings');
const have = rows.map(r => String(r.Key));
const add = Object.keys(SETTING_DEFAULTS).filter(k => have.indexOf(k) < 0).map(k => [k, SETTING_DEFAULTS[k]]);
if (add.length) shS.getRange(shS.getLastRow() + 1, 1, add.length, 2).setValues(add);
const pl = rows.find(r => String(r.Key) === 'platform_logo_url');
if (pl && (!str_(pl.Value).trim() || str_(pl.Value).trim().toLowerCase() === 'auto')) {
shS.getRange(pl._row, 2).setValue(PLATFORM_LOGO_URL);
}
const ppl = rows.find(r => String(r.Key) === 'platform_logo_light_url');
if (ppl && !str_(ppl.Value).trim()) {
shS.getRange(ppl._row, 2).setValue(PLATFORM_LOGO_LIGHT_URL);
}
}
/* ======================================================================
* GENERIC TABLE HELPERS
* ==================================================================== */
let META_ = {};
function ss_() { return SpreadsheetApp.getActiveSpreadsheet(); }
function tz_() { return Session.getScriptTimeZone() || 'Etc/GMT'; }
function getSheet_(name) {
const sh = ss_().getSheetByName(name);
if (!sh) throw new Error('Missing sheet "' + name + '". Reload the app to run setup.');
return sh;
}
function meta_(name) {
if (META_[name]) return META_[name];
const sh = getSheet_(name);
const width = Math.max(sh.getLastColumn(), HEADERS[name].length);
const row = sh.getRange(1, 1, 1, width).getValues()[0].map(String);
const idx = {};
row.forEach((h, i) => { if (h) idx[h] = i; });
HEADERS[name].forEach(h => {
if (idx[h] === undefined) throw new Error('Sheet "' + name + '" is missing column "' + h + '". Reload the app.');
});
return (META_[name] = { sh: sh, width: width, idx: idx });
}
function normalize_(v) {
if (v instanceof Date) return Utilities.formatDate(v, tz_(), 'yyyy-MM-dd');
return v;
}
function readTable_(name) {
const m = meta_(name);
const last = m.sh.getLastRow();
if (last < 2) return [];
const values = m.sh.getRange(2, 1, last - 1, m.width).getValues();
const key = HEADERS[name][0];
const out = [];
values.forEach((r, i) => {
if (String(r[m.idx[key]]) === '') return;
const o = { _row: i + 2 };
HEADERS[name].forEach(h => o[h] = normalize_(r[m.idx[h]]));
out.push(o);
});
return out;
}
function rowArray_(m, obj) {
const row = new Array(m.width).fill('');
Object.keys(obj).forEach(h => { if (m.idx[h] !== undefined) row[m.idx[h]] = (obj[h] === undefined || obj[h] === null) ? '' : obj[h]; });
return row;
}
function appendRow_(name, obj) { appendRows_(name, [obj]); }
function appendRows_(name, objs) {
if (!objs.length) return;
const m = meta_(name);
const rows = objs.map(o => rowArray_(m, o));
m.sh.getRange(m.sh.getLastRow() + 1, 1, rows.length, m.width).setValues(rows);
}
function updateRow_(name, id, obj) {
const m = meta_(name);
const last = m.sh.getLastRow();
if (last < 2) throw new Error('Record not found.');
const keyCol = m.idx[HEADERS[name][0]] + 1;
const ids = m.sh.getRange(2, keyCol, last - 1, 1).getValues().map(r => String(r[0]));
const i = ids.indexOf(String(id));
if (i < 0) throw new Error('Record not found.');
const rng = m.sh.getRange(i + 2, 1, 1, m.width);
const existing = rng.getValues()[0];
Object.keys(obj).forEach(h => { if (m.idx[h] !== undefined) existing[m.idx[h]] = (obj[h] === null || obj[h] === undefined) ? '' : obj[h]; });
rng.setValues([existing]);
}
function deleteWhere_(name, predicate) {
const m = meta_(name);
const rows = readTable_(name).filter(predicate);
rows.sort((a, b) => b._row - a._row).forEach(r => m.sh.deleteRow(r._row));
}
function strip_(o) { const c = Object.assign({}, o); delete c._row; return c; }
/* ======================================================================
* UTILITIES
* ==================================================================== */
function now_() { return Utilities.formatDate(new Date(), tz_(), 'yyyy-MM-dd HH:mm:ss'); }
function today_() { return Utilities.formatDate(new Date(), tz_(), 'yyyy-MM-dd'); }
function round2_(n) { return Math.round((Number(n) || 0) * 100) / 100; }
function str_(v) { return v === undefined || v === null ? '' : String(v); }
function bool_(v) { return v === true || String(v).toUpperCase() === 'TRUE'; }
function isEmail_(s) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(s || '').trim()); }
function me_() { return Session.getEffectiveUser().getEmail(); }
function addDays_(iso, n) {
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return iso;
return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3] + (Number(n) || 0))).toISOString().slice(0, 10);
}
function validDate_(v) { const m = String(v || '').match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? m[0] : ''; }
function fmtDate_(iso) {
const m = String(iso || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) return String(iso || '');
const M = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return m[3] + ' ' + M[+m[2] - 1] + ' ' + m[1];
}
function fmtMoney_(n, sym) {
n = Number(n) || 0;
const s = Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return (n < 0 ? '-' : '') + (sym || '$') + s;
}
function fmtQty_(n) { n = Number(n) || 0; return Number.isInteger(n) ? String(n) : String(round2_(n)); }
function escHtml_(s) {
return str_(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
function md5_(s) {
return Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, s, Utilities.Charset.UTF_8)
.map(b => ('0' + (b & 0xff).toString(16)).slice(-2)).join('');
}
/* ======================================================================
* SETTINGS
* ==================================================================== */
function getSettings_() {
const o = Object.assign({}, SETTING_DEFAULTS);
readTable_('Settings').forEach(r => o[String(r.Key)] = str_(r.Value));
return o;
}
function saveSettings(obj) {
ensureSetup();
obj = obj || {};
const sh = getSheet_('Settings');
const rows = readTable_('Settings');
const cache = CacheService.getScriptCache();
const put = (k, val) => {
const r = rows.find(x => String(x.Key) === k);
if (r) sh.getRange(r._row, 2).setValue(val);
else sh.getRange(sh.getLastRow() + 1, 1, 1, 2).setValues([[k, val]]);
};
SETTING_KEYS.forEach(k => { if (obj[k] !== undefined) put(k, str_(obj[k])); });
put('settings_updated_at', now_());
['logo_file_id', 'platform_logo_url', 'platform_logo_light_url'].forEach(k => {
if (obj[k] !== undefined) cache.remove('logo:' + md5_(str_(obj[k]).trim()));
});
cache.remove('logo:' + md5_(PLATFORM_LOGO_URL));
cache.remove('logo:' + md5_(PLATFORM_LOGO_FALLBACK));
cache.remove('logo:' + md5_(PLATFORM_LOGO_LIGHT_URL));
cache.remove('logo-discover:' + PLATFORM_SITE);
return getBootstrap();
}
function nextInvoiceNumber_() {
const lock = LockService.getScriptLock();
lock.waitLock(15000);
try {
const sh = getSheet_('Settings');
const rows = readTable_('Settings');
const r = rows.find(x => String(x.Key) === 'next_invoice_number');
let n = 1;
if (r) { n = parseInt(r.Value, 10) || 1; sh.getRange(r._row, 2).setValue(String(n + 1)); }
else sh.getRange(sh.getLastRow() + 1, 1, 1, 2).setValues([['next_invoice_number', '2']]);
const p = rows.find(x => String(x.Key) === 'invoice_prefix');
return (p ? str_(p.Value) : 'INV-') + String(n).padStart(4, '0');
} finally {
lock.releaseLock();
}
}
/* ======================================================================
* LOGOS (cached)
* ==================================================================== */
function logoDataUri_(ref) {
ref = str_(ref).trim();
if (!ref) return '';
const cache = CacheService.getScriptCache();
const key = 'logo:' + md5_(ref);
const hit = cache.get(key);
if (hit !== null) return hit === '-' ? '' : hit;
let uri = '';
try {
let blob = null;
if (/^https?:\/\//i.test(ref) && !/drive\.google\.com|docs\.google\.com/i.test(ref)) {
const candidates = [ref];
// Next.js optimizer URL → also try the original asset directly
const m = ref.match(/\/_next\/image\/?\?.*?url=([^&]+)/i);
if (m) { const o = decodeURIComponent(m[1]); candidates.push(/^https?:/i.test(o) ? o : resolveUrl_(ref, o)); }
for (let i = 0; i < candidates.length && !blob; i++) {
const res = UrlFetchApp.fetch(candidates[i], {
muteHttpExceptions: true, followRedirects: true, validateHttpsCertificates: true,
headers: { 'Accept': 'image/png,image/jpeg,image/gif;q=0.9,*/*;q=0.1',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36' }
});
const code = res.getResponseCode();
const ct = String(res.getHeaders()['Content-Type'] || res.getHeaders()['content-type'] || '').toLowerCase();
Logger.log('logo fetch %s → %s %s', candidates[i], code, ct);
if (code < 400 && /^imaCreate the app interface, index.html
In Apps Script, click the + next to Files → HTML, and name the file exactly: index Delete the starter HTML and paste the complete interface code below.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Completo Invoicing</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--ink:#1F2328;--graphite:#2B2F3A;--graphite-light:#EEF0F3;--graphite-wash:#F4F5F7;
--bg:#F7F8FA;--card:#FFFFFF;--text:#1F2328;--muted:#6B7280;--faint:#9CA3AF;--line:#E1E4EA;--hair:#EEF0F3;
--sb-bg:#1F2328;--sb-text:#B7BCC6;--sb-active:#FFFFFF;--sb-hover:rgba(255,255,255,.06);--sb-line:rgba(255,255,255,.08);
--paid:#E7F4EC;--paid-t:#1F6F43;--overdue:#FCEBEA;--overdue-t:#B42318;--sent:#E9EBF0;--sent-t:#2B2F3A;--draft:#F1F2F4;--draft-t:#6B7280;--amber:#FFF4DE;--amber-t:#8A5A00;
--indigo:#4F46E5;--indigo-deep:#3730A3;--indigo-soft:#EEF2FF;
--sb:236px;--radius:10px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.45;min-width:1200px}
button,input,select,textarea{font-family:inherit;font-size:inherit;color:inherit}
a{color:var(--indigo);text-decoration:none}
.ic{width:16px;height:16px;flex:none}
/* ---------- sidebar (compact spacing, original text sizes) ---------- */
.sidebar{position:fixed;left:0;top:0;bottom:0;width:var(--sb);background:var(--sb-bg);display:flex;flex-direction:column;padding:20px 14px 16px;z-index:30}
.brand{display:flex;align-items:center;gap:10px;padding:2px 8px 20px;min-height:56px}
.brand img{max-height:26px;max-width:190px;object-fit:contain;object-position:left;display:block}
.brand .mark{width:30px;height:30px;border-radius:8px;background:#fff;color:var(--ink);display:grid;place-items:center;font-weight:700;font-size:15px}
.brand .name{font-weight:700;font-size:14.5px;line-height:1.15;color:#fff}
.brand .name small{display:block;color:var(--sb-text);font-weight:500;font-size:11px}
.nav{display:flex;flex-direction:column;gap:2px}
.nav .sec{font-size:10.5px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:#6B7280;padding:14px 10px 6px}
.nav button{display:flex;align-items:center;gap:11px;border:0;background:transparent;padding:9px 10px;border-radius:8px;color:var(--sb-text);font-weight:500;cursor:pointer;text-align:left;width:100%}
.nav button .ic{width:18px;height:18px}
.nav button:hover{background:var(--sb-hover);color:#fff}
.nav button.active{background:rgba(255,255,255,.1);color:var(--sb-active);font-weight:600}
.nav button .cnt{margin-left:auto;font-size:11px;font-weight:600;color:#6B7280}
.nav button.active .cnt{color:#fff}
.sb-foot{margin-top:auto;padding:12px 10px 0;border-top:1px solid var(--sb-line);display:flex;align-items:center;gap:10px;color:#8A8FA3;font-size:12px}
.sb-foot .av{width:30px;height:30px;border-radius:50%;background:rgba(255,255,255,.1);color:#fff;display:grid;place-items:center;font-weight:700;font-size:12px}
.sb-foot div span{display:block;font-weight:600;color:#fff;font-size:12.5px;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sb-foot div div{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ---------- main (compact padding/gaps, original text sizes) ---------- */
.main{margin-left:var(--sb);min-height:100vh;padding:22px 26px 48px}
.page{display:none;max-width:1440px;margin:0 auto}
.page.active{display:block}
.ph{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}
.ph h1{font-size:21px;font-weight:700;letter-spacing:-.01em;display:flex;align-items:center;gap:12px}
.ph p{color:var(--muted);margin-top:3px;font-size:13px}
.ph .acts{display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:18px}
.card h2{font-size:14.5px;font-weight:600;margin-bottom:14px;display:flex;align-items:center;justify-content:space-between;gap:10px}
.card h2 .ic{color:var(--muted)}
.card h2 span{display:flex;align-items:center;gap:8px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start}
.grid3{display:grid;grid-template-columns:2fr 1fr;gap:16px;align-items:stretch}
/* ---------- stat cards: flat, no border decoration, no icon chip; only first is indigo ---------- */
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px}
.stat{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;display:flex;gap:14px;align-items:flex-start}
.stat .ico{color:var(--muted);display:grid;place-items:center;padding-top:2px}
.stat .ico .ic{width:28px;height:28px}
.stat .label{color:var(--muted);font-size:12px;font-weight:500}
.stat .value{font-size:22px;font-weight:700;margin-top:2px;letter-spacing:-.01em;font-variant-numeric:tabular-nums;color:var(--ink)}
.stat .sub{font-size:11.5px;color:var(--faint);margin-top:2px}
.stat.green .ico{color:var(--paid-t)}
.stat.amber .ico{color:var(--amber-t)}
.stat.red .ico{color:var(--overdue-t)}
.stat.primary{background:var(--indigo);border-color:var(--indigo)}
.stat.primary .ico{color:#fff}
.stat.primary .label{color:rgba(255,255,255,.85)}
.stat.primary .value{color:#fff}
.stat.primary .sub{color:rgba(255,255,255,.7)}
/* ---------- activity list ---------- */
.card-h{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;flex-wrap:wrap}
.card-h h2{margin:0}
.chart-kpis{display:flex;gap:22px}
.chart-kpis > div{display:flex;flex-direction:column;align-items:flex-end}
.chart-kpis span{font-size:10.5px;color:var(--faint);font-weight:600;text-transform:uppercase;letter-spacing:.07em}
.chart-kpis b{font-size:15px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--indigo);margin-top:2px}
.hbars{display:flex;flex-direction:column;gap:12px;padding:2px 0}
.hrow{display:grid;grid-template-columns:64px 1fr 116px;align-items:center;gap:14px;padding:5px 8px;border-radius:8px;transition:background .12s}
.hrow:hover{background:var(--graphite-wash)}
.hlbl{display:flex;flex-direction:column;line-height:1.15}
.hlbl b{font-size:12.5px;font-weight:600;color:var(--text)}
.hlbl small{font-size:10px;font-weight:500;color:var(--faint);margin-top:2px}
.hbars-stack{display:flex;flex-direction:column;gap:5px}
.hbar-track{position:relative;height:9px;background:#F1F2F5;border-radius:5px;overflow:hidden}
.hbar-fill{position:absolute;left:0;top:0;bottom:0;border-radius:5px;transition:width .5s ease;min-width:2px}
.hbar-fill.inv{background:#A5B4FC}
.hbar-fill.paid{background:var(--indigo)}
.hvals{display:flex;flex-direction:column;gap:5px;font-size:11.5px;font-variant-numeric:tabular-nums;text-align:right;font-weight:600;color:var(--text)}
.hvals > div{height:9px;display:flex;align-items:center;justify-content:flex-end;gap:5px}
.hvals .dot{width:6px;height:6px;border-radius:50%;flex:none}
.hvals .dot.inv{background:#A5B4FC}
.hvals .dot.paid{background:var(--indigo)}
.legend{display:flex;gap:16px;font-size:12px;color:var(--muted);margin-top:12px;padding-top:12px;border-top:1px solid var(--hair)}
.legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:6px;vertical-align:-1px;background:#A5B4FC}
.legend i.p{background:var(--indigo)}
.card.fill{display:flex;flex-direction:column}
.card.fill > .fill-body{flex:1;min-height:0;display:flex;flex-direction:column}
.card.fill > .fill-body > .empty-state{flex:1}
/* ---------- forms (original sizes) ---------- */
label{display:block;font-size:12px;font-weight:500;color:var(--muted);margin-bottom:5px}
.field{margin-bottom:14px}
input[type=text],input[type=number],input[type=date],input[type=email],input[type=url],select,textarea{
width:100%;border:1px solid var(--line);border-radius:8px;padding:9px 12px;background:#fff;outline:none;transition:border .15s;color:var(--text)}
input:focus,select:focus,textarea:focus{border-color:var(--indigo)}
input:disabled,select:disabled,textarea:disabled{background:var(--graphite-wash);color:var(--muted)}
textarea{resize:vertical;min-height:72px}
.row{display:grid;gap:12px}
.row.c2{grid-template-columns:1fr 1fr}.row.c3{grid-template-columns:1fr 1fr 1fr}
.inline{display:flex;gap:8px;align-items:flex-end}
.inline>.field{flex:1}
.check{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text);cursor:pointer;margin-bottom:14px}
.check input{width:16px;height:16px;accent-color:var(--indigo)}
.search{position:relative}
.search .ic{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--faint)}
.search input{padding-left:36px}
/* buttons — original sizes, default CTA indigo */
.btn{border:1px solid var(--indigo);border-radius:8px;padding:8px 14px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:7px;background:var(--indigo);color:#fff;transition:background .15s,border-color .15s;white-space:nowrap;line-height:1.2}
.btn:hover{background:var(--indigo-deep);border-color:var(--indigo-deep)}
.btn:disabled{opacity:.55;cursor:not-allowed}
.btn.ghost{background:var(--graphite-light);color:var(--graphite);border-color:var(--graphite-light)}
.btn.ghost:hover{background:#E3E6EB;border-color:#E3E6EB}
.btn.plain{background:#fff;color:var(--text);border-color:var(--line)}
.btn.plain:hover{background:var(--graphite-wash);border-color:#CBD0D8}
.btn.danger{background:#fff;color:var(--overdue-t);border-color:var(--line)}
.btn.danger:hover{background:var(--overdue);border-color:var(--overdue)}
.btn.sm{padding:6px 11px;font-size:12.5px}
.ib{border:0;background:transparent;color:var(--muted);width:28px;height:28px;border-radius:6px;display:inline-grid;place-items:center;cursor:pointer;transition:color .12s,background .12s}
.ib:hover{color:var(--ink);background:var(--graphite-light)}
.ib.danger:hover{color:var(--overdue-t);background:var(--overdue)}
.ib.ok:hover{color:var(--paid-t);background:var(--paid)}
.ib:disabled{opacity:.35;cursor:not-allowed}
.rowacts{display:flex;gap:2px;justify-content:flex-end}
/* tables (original sizes, compact row padding) */
table.tbl{width:100%;border-collapse:collapse}
table.tbl th{text-align:left;font-size:11.5px;color:var(--faint);font-weight:600;text-transform:uppercase;letter-spacing:.05em;padding:8px 10px;border-bottom:1px solid var(--line)}
table.tbl td{padding:10px 10px;border-bottom:1px solid var(--hair);vertical-align:middle}
table.tbl tr:last-child td{border-bottom:0}
table.tbl tbody tr:hover td{background:#FAFBFC}
table.tbl td.r,table.tbl th.r{text-align:right;font-variant-numeric:tabular-nums}
table.tbl .strong{font-weight:600}
table.tbl .sub{font-size:12px;color:var(--faint)}
table.tbl tr.click{cursor:pointer}
table.tbl tr.no-hover:hover td{background:transparent}
.badge{display:inline-block;padding:3px 9px;border-radius:6px;font-size:11.5px;font-weight:600;line-height:1.4}
.badge.Draft{background:var(--draft);color:var(--draft-t)}
.badge.Sent{background:var(--sent);color:var(--sent-t)}
.badge.Paid{background:var(--paid);color:var(--paid-t)}
.badge.Overdue{background:var(--overdue);color:var(--overdue-t)}
.badge.on{background:var(--paid);color:var(--paid-t)}
.badge.off{background:var(--draft);color:var(--draft-t)}
/* empty-state placeholder — flat icon, original sizes */
.empty-state{padding:32px 20px;text-align:center;color:var(--muted);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px}
.empty-state .es-ic{color:var(--faint);display:grid;place-items:center;margin-bottom:8px}
.empty-state .es-ic .ic{width:28px;height:28px}
.empty-state .es-title{font-weight:600;color:var(--text);font-size:13.5px}
.empty-state .es-sub{font-size:12.5px;color:var(--faint);line-height:1.55;max-width:360px}
.empty-state .es-actions{margin-top:12px;display:flex;gap:8px;justify-content:center;flex-wrap:wrap}
.toolbar{display:flex;align-items:center;gap:12px;margin-bottom:14px}
.pills{display:flex;gap:6px}
.pill{border:1px solid var(--line);background:#fff;border-radius:8px;padding:6px 12px;cursor:pointer;font-weight:500;color:var(--muted);font-size:13px;display:inline-flex;gap:6px;align-items:center}
.pill b{font-size:11px;color:var(--faint)}
.pill:hover{border-color:#CBD0D8;color:var(--text)}
.pill.active{background:var(--indigo);border-color:var(--indigo);color:#fff}
.pill.active b{color:#fff;opacity:.75}
.toolbar .search{margin-left:auto;width:280px}
/* editor */
.editor{display:grid;grid-template-columns:minmax(500px,1fr) minmax(540px,1.05fr);gap:16px;align-items:start}
.items{width:100%;border-collapse:collapse;margin-bottom:8px}
.items th{text-align:left;font-size:11px;color:var(--faint);font-weight:600;text-transform:uppercase;letter-spacing:.05em;padding:0 6px 6px}
.items td{padding:4px 3px;vertical-align:middle}
.items td input{padding:8px 10px}
.items .num input{text-align:right}
.items .amt{text-align:right;font-variant-numeric:tabular-nums;padding-right:6px;color:var(--muted);white-space:nowrap;font-weight:500}
.totals-mini{display:flex;justify-content:flex-end;gap:22px;color:var(--muted);font-size:13px;margin:10px 0 4px;font-variant-numeric:tabular-nums}
.totals-mini b{color:var(--text)}
.note{background:var(--amber);border:1px solid #F1DFB0;color:var(--amber-t);border-radius:8px;padding:10px 12px;font-size:12.5px;margin-bottom:14px;display:none;align-items:center;gap:8px}
.note.show{display:flex}
.sticky{position:sticky;top:22px}
/* preview paper — mirrors the PDF */
.paper{background:#fff;border:1px solid var(--line);padding:42px 44px 28px;min-height:900px;font-size:11.5px;color:#1F2328;display:flex;flex-direction:column;line-height:1.5}
.paper .hd{display:flex;justify-content:space-between;align-items:flex-end}
.paper .hd img{max-height:40px;max-width:170px;object-fit:contain}
.paper .hd .txtlogo{font-size:17px;font-weight:700}
.paper .doc{font-size:24px;font-weight:400;letter-spacing:.02em;line-height:1;text-align:right}
.paper .no{color:#6B7280;text-align:right;margin-top:5px;font-size:12px}
.paper .tag{display:inline-block;border:1px solid;border-radius:2px;padding:1px 8px;font-size:9.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;margin-top:7px}
.paper .tag.Paid{color:#1F6F43}.paper .tag.Draft{color:#6B7280;border-color:#9CA3AF}.paper .tag.Overdue{color:#B42318}
.paper .rule{border-top:1.5px solid #1F2328;margin:16px 0 18px}
.paper .cols{display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px}
.paper .block{font-size:11px;color:#3B4048;line-height:1.55;white-space:pre-line}
.paper .lbl{font-size:8.5px;letter-spacing:.15em;text-transform:uppercase;color:#6B7280;font-weight:700;margin-bottom:6px;white-space:normal}
.paper .kv{display:flex;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:.5px solid #E8EAEF;font-size:11px}
.paper .kv:last-of-type{border-bottom:0}
.paper .kv span:first-child{color:#6B7280}
.paper .kv b{font-weight:700}
.paper .due{background:#F4F5F7;padding:10px 12px;margin-top:10px}
.paper .due .lbl{margin-bottom:1px}
.paper .due .amt{font-size:18px;font-weight:700;line-height:1.2}
.paper table.it{width:100%;border-collapse:collapse;margin-top:22px}
.paper table.it th{font-size:8.5px;letter-spacing:.14em;text-transform:uppercase;color:#6B7280;text-align:left;padding:0 6px 7px;border-bottom:1.5px solid #1F2328;font-weight:700}
.paper table.it td{padding:9px 6px;border-bottom:.5px solid #E8EAEF;vertical-align:top}
.paper .r{text-align:right}.paper .c{text-align:center;color:#6B7280}
.paper .bottom{display:grid;grid-template-columns:1.15fr 1fr;gap:26px;margin-top:14px}
.paper table.tt{width:100%;border-collapse:collapse}
.paper table.tt td{padding:6px;border-bottom:.5px solid #E8EAEF}
.paper table.tt tr.g td{border-top:1.5px solid #1F2328;border-bottom:0;font-weight:700;font-size:14px;padding-top:9px}
.paper .ft{margin-top:auto;padding-top:14px;border-top:.5px solid #D9DCE3;display:grid;grid-template-columns:1.4fr 1fr;gap:14px;font-size:10.5px;color:#3B4048}
.paper .ft .thanks{text-align:right;color:#6B7280;line-height:1.55}
.paper .ft2{border-top:.5px solid #E8EAEF;margin-top:10px;padding-top:6px;font-size:9.5px;color:#9CA3AF;display:flex;justify-content:space-between;align-items:center}
.paper .ft2 img{height:9px;vertical-align:-1px;margin-left:3px}
.paper .dim{color:#B0B4C6;font-style:italic}
/* lists */
.list{display:flex;flex-direction:column;gap:6px}
.list button{text-align:left;border:1px solid var(--line);background:#fff;border-radius:8px;padding:10px 12px;cursor:pointer;font-weight:500;display:flex;align-items:center;gap:10px;color:var(--text)}
.list button .ic{color:var(--faint)}
.list button:hover{border-color:#CBD0D8}
.list button.active{border-color:var(--indigo);background:var(--indigo-soft)}
.list button.active .ic{color:var(--indigo)}
.hint{font-size:12px;color:var(--faint);margin-top:6px;line-height:1.6}
code{background:var(--graphite-light);padding:1px 6px;border-radius:4px;font-size:11.5px;color:var(--text)}
.logo-prev{display:flex;align-items:center;gap:12px;background:var(--graphite-wash);border:1px dashed var(--line);border-radius:8px;padding:10px 12px;min-height:56px;font-size:12px;color:var(--faint);margin-top:8px}
.logo-prev img{max-height:32px;max-width:170px;object-fit:contain}
/* top clients card */
.top-list{display:flex;flex-direction:column;gap:8px}
.top-row{display:grid;grid-template-columns:34px 1fr auto;gap:12px;align-items:center;padding:8px 6px;border-radius:8px;cursor:pointer;transition:background .12s}
.top-row:hover{background:var(--graphite-wash)}
.top-av{width:34px;height:34px;border-radius:8px;background:var(--indigo-soft);color:var(--indigo-deep);display:grid;place-items:center;font-weight:700;font-size:13px}
.top-info{display:flex;flex-direction:column;line-height:1.25;min-width:0}
.top-info b{font-size:13px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.top-info small{font-size:11.5px;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.top-amt{text-align:right;font-variant-numeric:tabular-nums;font-weight:700;font-size:13px;color:var(--indigo-deep)}
.top-amt small{display:block;font-weight:500;color:var(--faint);font-size:11px;margin-top:2px}
/* disclaimer block */
.disclaimer{font-size:12.5px;color:var(--muted);line-height:1.75}
.disclaimer p{margin:0 0 10px}
.disclaimer p:last-child{margin-bottom:0}
.disclaimer b{color:var(--text);font-weight:600}
.disclaimer-tag{display:inline-flex;align-items:center;gap:7px;background:var(--amber);color:var(--amber-t);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;padding:5px 10px;border-radius:6px;margin-bottom:14px}
.disclaimer-tag .ic{width:13px;height:13px}
/* catalog picker */
.cat{display:flex;flex-direction:column;gap:6px;max-height:380px;overflow:auto;margin-top:10px}
.cat button{display:flex;align-items:center;gap:12px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:10px 12px;cursor:pointer;text-align:left}
.cat button:hover{border-color:var(--indigo);background:var(--indigo-soft)}
.cat button .ic{color:var(--muted)}
.cat button div{flex:1}
.cat button small{display:block;color:var(--faint);font-size:12px}
.cat button b{font-variant-numeric:tabular-nums}
/* modals */
.modal-bg{position:fixed;inset:0;background:rgba(31,35,40,.45);display:none;align-items:center;justify-content:center;z-index:50}
.modal-bg.open{display:flex}
.modal{background:#fff;border:1px solid var(--line);border-radius:12px;width:580px;max-height:90vh;overflow:auto;padding:24px}
.Save the project
Click Save in Apps Script. You can rename the Apps Script project to something like: Completo Invoicing Make sure both files exist: Code.gs index.html
Authorize Google Sheets, Drive and Gmail
In the Apps Script editor, select the function: authorizeOnce Then click Run. Google will ask you to authorize the services the app needs. This is required because the app reads and writes your Google Sheet, creates invoice files in Google Drive, and sends invoice emails using your Google account. Complete the authorization prompts, then return to Apps Script.
Deploy the invoicing app
In Apps Script, click: Deploy → New deployment Choose Web app. Give the deployment a description such as: Completo Invoicing Use the most restrictive access setting that works for your own use case. This application can contain client and invoice information, so do not expose a private business system publicly unless you intentionally need to. Click Deploy, then open the Web App URL.
Prefer a ready-made file? Browse the store — the finished templates, with the formulas already built.