# Run a small-business CRM in Google Sheets with AI-written Apps Script

> For a 1–5 person services business: a structured Google Sheet plus a short Apps Script (written and debugged with Claude or ChatGPT) gives you a pipeline, follow-up reminders and a daily email digest without a paid CRM seat. Try HubSpot's free CRM first if you don't want to maintain anything.

- Outcome: Track leads and deals, never miss a follow-up, and see the pipeline at a glance — without paying per seat for a CRM.
- Replaces: Pipedrive (full), HubSpot Sales Hub (full), Salesforce Sales Cloud (partial)
- Uses: Google Workspace — Google Sheets holds the data; Apps Script adds the timestamps, follow-up view and digest; MailApp sends the email. A free Gmail account also works, with lower email quotas.; Claude (optional) — Writes, adapts and debugs the Apps Script and formulas for your exact columns.; ChatGPT (optional) — Alternative to Claude for writing and debugging the script.
- Extra monthly cost: $0 — Google Sheets and Apps Script are included with Google Workspace and with free Google accounts. Using an AI assistant you already pay for adds nothing. The real cost is your time: about half a day to set up and ~15 minutes a month of upkeep.
- Difficulty: moderate; skill: basic; setup: 3–5 hours (sheet design, script install, one week of parallel use)
- Evidence: editorial; illustrative example: true; last reviewed 2026-10-13
- Canonical: https://onelesstool.com/workflows/small-business-crm-in-google-sheets

## Good fit for
- Agencies, consultants, studios, trades and other services firms with 1–5 people selling
- Fewer than a few thousand open contacts and a few dozen open deals at a time
- Teams already living in Google Workspace who want follow-up discipline more than automation
- Founders who outgrew a to-do list but find a CRM's per-seat price hard to justify

## Not a fit for
- More than ~5 people selling at once — concurrent edits, ownership rules and permissions get messy in a shared sheet
- Teams that rely on automatic email/calendar sync, open/click tracking or sales sequences — a sheet has none of these
- Anyone who needs forecasting, weighted pipeline by rep, quotas or multi-currency reporting out of the box
- Regulated businesses that need a tamper-evident audit trail, field-level permissions or data-retention controls (a sheet's version history is not a compliance audit log)
- People who will not maintain a script — if nobody owns it, start with HubSpot's free CRM instead

## Before
Each person pays for a CRM seat (HubSpot Starter, Pipedrive or a light Salesforce edition). Deals live in a pipeline board; the CRM reminds you of follow-ups and logs email automatically. In practice a small team uses maybe 20% of it: a list of deals, a stage, a next step and a reminder.

## After
One shared Google Sheet with Deals, Companies, Activities and Lists tabs, dropdown validation for stages and owners, and a 150-line Apps Script. Changing a deal's stage stamps the time and writes to a Stage log. Overdue rows turn red, due-today rows amber, a 'Follow-ups due' tab lists everything that needs action, and every morning each owner gets an email of their overdue follow-ups. An AI assistant writes any changes to the script or formulas when you need them.

## Steps

### 1. Decide whether you should build this at all

Before building, check two cheaper options:

- **HubSpot's free CRM** is free with no expiry and covers **up to 2 users and 1,000 contacts** (per HubSpot's product page), including deal pipelines and email tracking. If you're two people with under 1,000 contacts, it's the no-maintenance choice.
- **Your current CRM's export.** Export deals and contacts to CSV now (every major CRM supports it). You need this file either way.

Build the sheet if you're 3–5 people, you've hit the free tier's limits, or you want something you fully control.

### 2. Create the sheet structure

Create a new Google Sheet called **CRM** and add the tabs from the *Sheet schema* artifact: **Deals**, **Companies**, **Activities**, **Lists**.

1. Type the Deals headers **exactly** as listed (the script finds columns by header name, so order doesn't matter but spelling does).
2. Fill **Lists** with your stages, lead sources and team emails. Keep `Won` and `Lost` spelled exactly.
3. Add data validation (**Data → Data validation**):
   - `Deals!F2:F` → *Dropdown (from a range)* `Lists!A2:A`, *Reject the input*
   - `Deals!E2:E` → dropdown from `Lists!C2:C`
   - `Deals!I2:I` → *Is valid date*
   - `Deals!D2:D` → *Text is valid email*
4. **View → Freeze → 1 row** on every tab, and turn on a filter (**Data → Create a filter**) on Deals.

### 3. Import your existing data

Open your CRM export in a separate tab, then map it into Deals:

- Paste each column under the matching header. Use `=PROPER(TRIM(B2))` style helpers to clean names, then *Paste special → Values only*.
- Make sure **Stage** values match the Lists tab exactly. Find mismatches with `=FILTER(F2:F, ISNA(MATCH(F2:F, Lists!A2:A, 0)), F2:F<>"")`.
- Convert follow-up dates to real dates (they should right-align). If they're text, use `=DATEVALUE()` or ask your AI assistant for a formula that handles your export's format.
- Leave **Stage changed at** and **Created at** blank — the script fills them from now on. If you want history, paste the old CRM's stage-change dates there once.

Keep the raw export in a hidden tab called `_import` for 90 days in case you need to check something.

### 4. Install the Apps Script

1. **Extensions → Apps Script**, delete the placeholder, paste the *Apps Script* artifact, and **Save**.
2. **Project Settings** (gear icon) → set the **time zone** to yours. The digest runs at `DIGEST_HOUR` in that zone.
3. Reload the spreadsheet. A **CRM** menu appears.
4. Run **CRM → Set up (run once)** and approve the permissions Google asks for (Sheets, sending email as you, running triggers).
5. Test: change a deal's Stage. Within a second or two **Stage changed at** fills in and a row appears in **Stage log**.
6. Run **CRM → Send follow-up digest now** with one overdue test deal assigned to yourself.

Why two kinds of trigger: `onEdit` is a *simple trigger*, so it runs without authorisation but **cannot send email**, is limited to 30 seconds and **does not fire for edits made by scripts or the API**. The digest uses an *installable* time-driven trigger, which can send mail.

### 5. Adapt it with an AI assistant instead of hand-coding

When you want a change (an extra column, a weekly pipeline summary, a different stage list), paste the whole script plus your header row into Claude or ChatGPT with the **Modify the CRM script** prompt. Rules that keep this safe:

- Always give the assistant your **exact header row** — most bugs are mismatched column names.
- Ask for the **complete file** back, not a fragment, and keep the previous version — the simplest habit is pasting the old script into a dated Google Doc before replacing it.
- Test on a **copy** of the spreadsheet (**File → Make a copy** copies the script too) before changing the live one.
- If you get an error, paste the exact message from **Executions** (left sidebar in the Apps Script editor) using the **Debug an Apps Script error** prompt.

### 6. Run both systems in parallel for one week, then cancel

Keep the old CRM read-only for a week while the team works only in the sheet. Every day, check the digest against what the old CRM would have reminded you about. When the verification checklist passes, export a final full backup from the old CRM, store it in Drive, then downgrade or cancel. Put a monthly 10-minute calendar reminder to archive Won/Lost deals older than 12 months to an `Archive` tab so the sheet stays fast.

## Prompts

### Modify the CRM script
Safely change the Apps Script for a new column, rule or report.

```text
You are an experienced Google Apps Script developer. Below is my CRM spreadsheet's current Apps Script and the exact header row of my {{sheet_name}} tab.

Header row (tab-separated, exact spelling):
{{header_row}}

Current script:
```javascript
{{current_script}}
```

Change request: {{change_request}}

Rules:
- Return the COMPLETE updated script in one code block, not a diff.
- Find columns by header name, never by hard-coded column number.
- Keep onEdit as a simple trigger: no MailApp, GmailApp or other services that need authorisation inside it.
- Stay within Apps Script quotas (6 minutes per execution; MailApp recipient limits) and read/write ranges in bulk where possible.
- After the code, list: (1) what changed, (2) any new permission Google will ask for, (3) a 3-step manual test I can run on a copy of the sheet.
```

### Debug an Apps Script error
Turn an error from the Executions log into a fix.

```text
My Google Apps Script for a Sheets CRM fails. Explain the cause in plain English, then give the corrected function(s).

Function that failed: {{function_name}}
Trigger type: {{trigger_type}}
Exact error message and line from Executions:
{{error_message}}

Header row of the tab it reads:
{{header_row}}

Relevant code:
```javascript
{{code_snippet}}
```

If the cause is a permissions, quota or simple-trigger restriction rather than a code bug, say so and tell me what to change in the setup instead.
```

### Weekly pipeline review from a CSV export
Get a short pipeline review without a CRM reporting module.

```text
Here is this week's export of my Deals tab as CSV (columns include Stage, Value, Owner, Next follow-up, Stage changed at, Created at):

{{deals_csv}}

Today is {{today}}. Our stages in order are: {{stage_order}}.

Produce:
1. Open pipeline value and deal count by stage (table).
2. Deals with no Next follow-up date, or a follow-up more than 7 days overdue (table: Company, Owner, days overdue).
3. Deals that have sat in the same stage for more than {{stale_days}} days, using Stage changed at.
4. Won and Lost this week, with lost reasons.
5. Three specific actions for the team, each naming a deal.
Use only the data provided. If a value is missing or ambiguous, say so instead of guessing.
```

## Templates and scripts

### Sheet schema (tabs, columns, validation) (schema)

```
SPREADSHEET: "CRM" (one Google Sheets file, shared with the team as Editors)

TAB 1 — Deals  (one row per opportunity; row 1 = headers, exact names matter for the script)
| Col | Header            | Type / validation                                                      |
|-----|-------------------|------------------------------------------------------------------------|
| A   | Deal ID           | Text. Formula in A2 (optional): =IF(B2="","","D-"&TEXT(ROW()-1,"0000")) |
| B   | Company           | Text. Data validation: dropdown from a range = Companies!A2:A           |
| C   | Contact name      | Text                                                                   |
| D   | Contact email     | Text. Data validation: "Text is valid email"                            |
| E   | Owner             | Email of the teammate who owns the deal. Dropdown from Lists!C2:C       |
| F   | Stage             | Dropdown from Lists!A2:A (reject invalid input)                         |
| G   | Value             | Number, format Currency. Validation: number >= 0                        |
| H   | Next step         | Text — a verb phrase: "Send revised quote", "Call re: start date"       |
| I   | Next follow-up    | Date. Validation: "is valid date"                                       |
| J   | Stage changed at  | Date-time. Filled by the script — do not type here                      |
| K   | Created at        | Date-time. Filled by the script on first stage selection                |
| L   | Source            | Dropdown from Lists!B2:B                                                |
| M   | Lost reason       | Text (fill when Stage = Lost)                                           |
| N   | Notes             | Text — short; long history goes in the Activities tab                   |

TAB 2 — Companies
| A Company | B Website | C Industry | D City | E Size (employees) | F Notes |

TAB 3 — Activities  (append-only log; use a Google Form or type directly)
| A Date | B Deal ID | C Type (Call / Email / Meeting / Note) | D Summary | E Logged by |
Tip: =FILTER(Activities!A:E, Activities!B:B = A2) in a side panel shows a deal's history.

TAB 4 — Lists  (single source for dropdowns)
| A Stage          | B Source        | C Owner              |
|------------------|-----------------|----------------------|
| Lead             | Referral        | you@yourfirm.com     |
| Qualified        | Website form    | colleague@yourfirm.com |
| Proposal sent    | Event           |                      |
| Negotiation      | Outbound        |                      |
| Won              | Existing client |                      |
| Lost             |                 |                      |
Keep "Won" and "Lost" spelled exactly like this — the script treats them as closed.

TAB 5 — Follow-ups due   (created by the script; formula-driven, do not edit by hand)
TAB 6 — Stage log        (created by the script; one row per stage change)

PROTECTION: Data > Protect sheets and ranges → protect "Follow-ups due", "Stage log" and
Deals!J:K with "Show a warning when editing this range" so nobody overwrites them by accident.

```

### Apps Script: stage timestamps, follow-ups view, daily digest (script)

```javascript
/**
 * Small-business CRM helpers for Google Sheets.
 * Install: Extensions > Apps Script > paste this file > Save.
 * Reload the spreadsheet, then run CRM > "Set up (run once)" and approve the permissions.
 *
 * Adds:
 *  1. onEdit timestamp: when Stage changes, writes "Stage changed at" (and "Created at" if blank)
 *     and appends a row to the "Stage log" tab.
 *  2. A "Follow-ups due" tab (live FILTER formula) plus red/amber highlighting on Deals.
 *  3. A daily email digest of overdue / due-today follow-ups, one email per Owner, via MailApp.
 */
const CONFIG = {
  DEALS_SHEET: 'Deals',
  DUE_SHEET: 'Follow-ups due',
  LOG_SHEET: 'Stage log',
  CLOSED_STAGES: ['Won', 'Lost'],
  DIGEST_HOUR: 7,        // hour of day in the script time zone (Project Settings)
  FALLBACK_EMAIL: '',    // gets deals with an empty Owner; blank = the person who ran setup
};

const COL = {
  ID: 'Deal ID', COMPANY: 'Company', CONTACT: 'Contact name', EMAIL: 'Contact email',
  OWNER: 'Owner', STAGE: 'Stage', VALUE: 'Value', NEXT_STEP: 'Next step',
  FOLLOW_UP: 'Next follow-up', STAGE_CHANGED: 'Stage changed at', CREATED: 'Created at',
};

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('CRM')
    .addItem('Set up (run once)', 'setup')
    .addItem('Rebuild follow-ups view', 'buildFollowUpsView')
    .addItem('Send follow-up digest now', 'sendFollowUpDigest')
    .addToUi();
}

/** Maps header text -> 1-based column number. Throws if a required header is missing. */
function headerMap_(sheet) {
  const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
  const map = {};
  headers.forEach(function (h, i) { if (h !== '') map[String(h).trim()] = i + 1; });
  Object.keys(COL).forEach(function (k) {
    if (!map[COL[k]]) throw new Error('Missing column "' + COL[k] + '" on tab ' + sheet.getName());
  });
  return map;
}

function colLetter_(n) {
  let s = '';
  while (n > 0) { const m = (n - 1) % 26; s = String.fromCharCode(65 + m) + s; n = Math.floor((n - 1) / 26); }
  return s;
}

/* ---------- 1. Stage-change timestamps (simple trigger) ---------- */
function onEdit(e) {
  if (!e || !e.range) return;
  const sheet = e.range.getSheet();
  if (sheet.getName() !== CONFIG.DEALS_SHEET || e.range.getLastRow() < 2) return;
  let cols;
  try { cols = headerMap_(sheet); } catch (err) { e.source.toast(err.message, 'CRM script'); return; }
  const stageCol = cols[COL.STAGE];
  if (stageCol < e.range.getColumn() || stageCol > e.range.getLastColumn()) return;

  const now = new Date();
  const singleCell = e.range.getNumRows() === 1 && e.range.getNumColumns() === 1;
  const editor = (e.user && e.user.getEmail && e.user.getEmail()) || '';
  const log = e.source.getSheetByName(CONFIG.LOG_SHEET);
  const firstRow = Math.max(2, e.range.getRow());

  for (let r = firstRow; r <= e.range.getLastRow(); r++) {
    const stage = sheet.getRange(r, stageCol).getValue();
    sheet.getRange(r, cols[COL.STAGE_CHANGED]).setValue(now);
    const created = sheet.getRange(r, cols[COL.CREATED]);
    if (created.getValue() === '') created.setValue(now);
    if (log) {
      log.appendRow([
        now,
        sheet.getRange(r, cols[COL.ID]).getValue(),
        sheet.getRange(r, cols[COL.COMPANY]).getValue(),
        singleCell ? (e.oldValue || '') : '(bulk edit)',
        stage,
        editor,
      ]);
    }
  }
}

/* ---------- 2. Follow-ups view + highlighting ---------- */
function buildFollowUpsView() {
  const ss = SpreadsheetApp.getActive();
  const deals = ss.getSheetByName(CONFIG.DEALS_SHEET);
  const cols = headerMap_(deals);
  const lastCol = colLetter_(deals.getLastColumn());
  const f = colLetter_(cols[COL.FOLLOW_UP]);
  const s = colLetter_(cols[COL.STAGE]);
  const src = "'" + CONFIG.DEALS_SHEET + "'!";

  const conditions = [src + f + '2:' + f + '<>""', src + f + '2:' + f + '<=TODAY()']
    .concat(CONFIG.CLOSED_STAGES.map(function (st) { return src + s + '2:' + s + '<>"' + st + '"'; }));
  const formula = '=IFERROR(SORT(FILTER(' + src + 'A2:' + lastCol + ', ' + conditions.join(', ') +
    '), ' + cols[COL.FOLLOW_UP] + ', TRUE), "Nothing due. Nice.")';

  const due = ss.getSheetByName(CONFIG.DUE_SHEET) || ss.insertSheet(CONFIG.DUE_SHEET);
  due.clear();
  const headers = deals.getRange(1, 1, 1, deals.getLastColumn()).getValues();
  due.getRange(1, 1, 1, headers[0].length).setValues(headers).setFontWeight('bold');
  due.getRange('A2').setFormula(formula);
  due.setFrozenRows(1);
}

function addHighlighting_() {
  const deals = SpreadsheetApp.getActive().getSheetByName(CONFIG.DEALS_SHEET);
  const cols = headerMap_(deals);
  const f = '$' + colLetter_(cols[COL.FOLLOW_UP]) + '2';
  const s = '$' + colLetter_(cols[COL.STAGE]) + '2';
  const open = CONFIG.CLOSED_STAGES.map(function (st) { return s + '<>"' + st + '"'; }).join(',');
  const overdue = '=AND(' + f + '<>"",' + f + '<TODAY(),' + open + ')';
  const today = '=AND(' + f + '<>"",' + f + '=TODAY(),' + open + ')';
  const range = deals.getRange(2, 1, deals.getMaxRows() - 1, deals.getLastColumn());

  const rules = deals.getConditionalFormatRules().filter(function (r) {
    const c = r.getBooleanCondition();
    const v = c && c.getCriteriaValues()[0];
    return v !== overdue && v !== today; // drop our old copies so re-running setup doesn't duplicate
  });
  rules.push(SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied(overdue)
    .setBackground('#f4c7c3').setRanges([range]).build());
  rules.push(SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied(today)
    .setBackground('#fce8b2').setRanges([range]).build());
  deals.setConditionalFormatRules(rules);
}

/* ---------- 3. Daily digest (installable time-driven trigger) ---------- */
function sendFollowUpDigest() {
  const ss = SpreadsheetApp.getActive();
  const deals = ss.getSheetByName(CONFIG.DEALS_SHEET);
  const cols = headerMap_(deals);
  const values = deals.getDataRange().getValues();
  const fallback = CONFIG.FALLBACK_EMAIL || Session.getEffectiveUser().getEmail();

  const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0);
  const startOfTomorrow = new Date(startOfToday.getTime() + 86400000);
  const byOwner = {};

  for (let i = 1; i < values.length; i++) {
    const row = values[i];
    const due = row[cols[COL.FOLLOW_UP] - 1];
    const stage = String(row[cols[COL.STAGE] - 1]);
    if (!(due instanceof Date) || CONFIG.CLOSED_STAGES.indexOf(stage) !== -1) continue;
    if (due.getTime() >= startOfTomorrow.getTime()) continue;
    const dueDay = new Date(due); dueDay.setHours(0, 0, 0, 0);
    const owner = String(row[cols[COL.OWNER] - 1] || fallback).trim();
    (byOwner[owner] = byOwner[owner] || []).push({
      company: row[cols[COL.COMPANY] - 1], contact: row[cols[COL.CONTACT] - 1],
      stage: stage, next: row[cols[COL.NEXT_STEP] - 1], value: row[cols[COL.VALUE] - 1],
      daysLate: Math.round((startOfToday - dueDay) / 86400000), row: i + 1,
    });
  }

  const url = ss.getUrl() + '#gid=' + deals.getSheetId();
  const tz = ss.getSpreadsheetTimeZone();
  Object.keys(byOwner).forEach(function (owner) {
    if (MailApp.getRemainingDailyQuota() < 1) { console.warn('MailApp daily quota used up'); return; }
    const items = byOwner[owner].sort(function (a, b) { return b.daysLate - a.daysLate; });
    const rowsHtml = items.map(function (d) {
      return '<tr><td>' + esc_(d.company) + '</td><td>' + esc_(d.contact) + '</td><td>' + esc_(d.stage) +
        '</td><td>' + esc_(d.next) + '</td><td style="text-align:right">' + esc_(d.value) + '</td><td>' +
        (d.daysLate === 0 ? 'today' : d.daysLate + ' day(s) late') + '</td><td>row ' + d.row + '</td></tr>';
    }).join('');
    const html = '<p>' + items.length + ' follow-up(s) due as of ' +
      Utilities.formatDate(startOfToday, tz, 'EEE d MMM yyyy') + ':</p>' +
      '<table border="1" cellpadding="6" style="border-collapse:collapse;font-family:sans-serif;font-size:13px">' +
      '<tr><th>Company</th><th>Contact</th><th>Stage</th><th>Next step</th><th>Value</th><th>Due</th><th>Where</th></tr>' +
      rowsHtml + '</table><p><a href="' + url + '">Open the CRM</a></p>';
    const text = items.map(function (d) {
      return '- ' + d.company + ' (' + d.stage + '): ' + d.next + ' [' + (d.daysLate === 0 ? 'today' : d.daysLate + 'd late') + ']';
    }).join('\n') + '\n\n' + url;
    MailApp.sendEmail({ to: owner, subject: 'CRM: ' + items.length + ' follow-up(s) due', body: text, htmlBody: html });
  });
}

function esc_(v) {
  return String(v === null || v === undefined ? '' : v)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

/* ---------- One-time setup ---------- */
function setup() {
  const ss = SpreadsheetApp.getActive();
  if (!ss.getSheetByName(CONFIG.LOG_SHEET)) {
    ss.insertSheet(CONFIG.LOG_SHEET)
      .appendRow(['Changed at', 'Deal ID', 'Company', 'From stage', 'To stage', 'Changed by']);
  }
  buildFollowUpsView();
  addHighlighting_();
  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (t.getHandlerFunction() === 'sendFollowUpDigest') ScriptApp.deleteTrigger(t);
  });
  ScriptApp.newTrigger('sendFollowUpDigest').timeBased().everyDays(1).atHour(CONFIG.DIGEST_HOUR).create();
  ss.toast('CRM set up: follow-ups view, highlighting and daily digest at ~' + CONFIG.DIGEST_HOUR + ':00.', 'CRM', 8);
}

```

## Limitations
- No email or calendar sync: emails and meetings are not logged automatically; people must add a line to Activities or the Notes column.
- No sequences, open/click tracking or meeting-booking links — those need a CRM or a separate tool.
- Simple onEdit triggers do not fire for changes made by scripts, the Sheets API, Zapier-style integrations or form submissions, so those rows get no stage timestamp.
- Shared-sheet permissions are all-or-nothing per tab/range: anyone with edit access can see and change every deal.
- MailApp limits: 100 email recipients/day on a free Google account and 1,500/day on Google Workspace; triggers share a daily runtime quota (90 min consumer, 6 h Workspace).
- Reporting is whatever you build: pivot tables, charts or a Looker Studio dashboard — there's no forecasting engine.
- The digest's date logic uses the script's time zone; teammates in other time zones receive it at that zone's hour.

## Risks
- **functionality_loss** (medium): You lose automatic activity capture, mobile CRM apps, email tracking and built-in reports. Follow-up discipline depends on people updating the sheet.
- **reliability** (medium): If someone renames a header, the script stops (it shows a toast and the Executions log records the error). The digest silently stops if the trigger owner leaves the company or their account is suspended — reinstall the trigger from a shared admin account.
- **security** (medium): Customer contact details sit in a spreadsheet that can be shared or downloaded with a click. Restrict sharing to named people, disable 'Viewers and commenters can download, print and copy', and never share by link.
- **compliance** (low): Sheets version history is not a tamper-proof audit log, and deletion/retention requests (e.g. GDPR erasure) must be handled manually across tabs, the Stage log and version history.
- **migration_complexity** (low): Importing from a CRM is mostly copy-paste, but activity history and attachments rarely export cleanly. Keep the final CRM export archived.

## Verification checklist (run before cancelling)
- [ ] All open deals from the old CRM are in Deals: row count matches the old CRM's open-deal count, and total open Value matches within rounding.
- [ ] Changing a Stage in the sheet fills 'Stage changed at' within a few seconds and appends a row to 'Stage log' showing the old and new stage.
- [ ] A test deal with yesterday's follow-up date turns red, one dated today turns amber, and both appear on the 'Follow-ups due' tab; a Won deal with an old date does not appear.
- [ ] 'CRM → Send follow-up digest now' delivers an email to each owner listing only their overdue/due-today deals, with a working link to the sheet.
- [ ] The time-driven trigger shows in Apps Script → Triggers, and the digest arrived unprompted on at least 3 consecutive mornings.
- [ ] Every teammate has logged at least one deal and one activity without help, and nobody needed the old CRM during a full week of parallel running.
- [ ] A complete CSV export of the old CRM (contacts, companies, deals, notes) is saved in a restricted Drive folder before cancelling.

## Savings methodology
Savings = (CRM price per seat per month × number of paid seats) − incremental cost of the replacement. The replacement's incremental cost is $0 because Sheets, Apps Script and MailApp come with Google accounts you already have, and an AI assistant you already pay for. We do not count setup time (≈half a day) or maintenance (≈15 min/month) as dollars; if your time is billable, subtract it. If your current plan is billed annually, savings only start at renewal unless the vendor refunds unused time.

Example (illustrative): A 3-person design studio on a paid CRM plan moves its pipeline to Google Sheets. $120/mo → $0/mo. Assumptions: ILLUSTRATIVE: 3 paid CRM seats at an assumed $40 per seat per month. We could not verify current Pipedrive or HubSpot paid-plan prices on the vendors' own pages today (Pipedrive's pricing page blocked automated access; HubSpot's pricing page did not show prices) — use the amount on your own invoice. The studio already pays for Google Workspace and an AI assistant, so nothing new is bought. HubSpot's free CRM (up to 2 users, 1,000 contacts — verified on HubSpot's product page) would also cost $0 but doesn't fit 3 users.

## Sources
- HubSpot free CRM product page: https://www.hubspot.com/products/crm (accessed 2026-10-13)
- Apps Script simple triggers — restrictions: https://developers.google.com/apps-script/guides/triggers (accessed 2026-10-13)
- Apps Script quotas: https://developers.google.com/apps-script/guides/services/quotas (accessed 2026-10-13)
- Pipedrive pricing: https://www.pipedrive.com/en/pricing (accessed 2026-10-13)
- HubSpot Sales Hub pricing: https://www.hubspot.com/pricing/sales (accessed 2026-10-13)
