{
  "id": "aZNLOO5ezPuHo9jY",
  "name": "SaaS Subscription Auditor",
  "tags": [],
  "nodes": [
    {
      "id": "91fd21dc-6408-4145-97a9-2a6eb729841d",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -2320,
        -192
      ],
      "parameters": {
        "color": 4,
        "width": 636,
        "height": 1252,
        "content": "## SaaS Subscription Waste Auditor \u2014 Gmail + GPT-4.1-mini + Google Sheets + HTML Email Report\n\nFor teams, agencies, and individuals who want to automatically audit all SaaS subscription charges in their Gmail inbox, classify waste, and receive a weekly HTML digest showing exactly how much money can be recovered. This workflow scans Gmail for billing and subscription emails from the last 30 days using a broad keyword search. A Code node prefilters marketing noise \u2014 keeping only invoices, receipts, renewals, and billing confirmations. For each email the full body is fetched. An AI Agent powered by GPT-4.1-mini runs a 5-step analysis: classify billing vs non-billing, extract 18 structured fields (vendor, amount, cycle, renewal date, waste type), optionally call Gmail search and Get Message tools to confirm amounts and check for usage signals, classify waste type (OK / UNUSED / ZOMBIE / ANNUAL_RENEWAL / RENEWAL_SOON), and compute confidence score with routing (_target: dashboard / review / skip). A Code node validates and cleans the JSON. Google Sheets upserts one row per vendor (Vendor as match key). A Code node builds a branded HTML report with stat boxes, flagged subscription table, and cost summary. Gmail sends the report.\n\nThis workflow also includes an embedded sub-workflow (bottom) that handles Gmail search tool calls invoked by the AI Agent.\n\n## How it works\n- **1. Schedule \u2014 Monthly Billing Scan** triggers the pipeline (currently set to monthly \u2014 edit cron to run weekly)\n- **2. Gmail \u2014 Search Billing and Usage Emails** searches last 30 days for billing, invoice, subscription, and re-engagement keyword matches \u2014 limit 500\n- **3. Code \u2014 Prefilter Noise** drops newsletters, promos, OTPs \u2014 keeps only real billing signals\n- **4. Gmail \u2014 Get Email Body** fetches the full email body for each filtered message\n- **5. AI Agent \u2014 Extract and Analyze Subscription** runs 5-step analysis using GPT-4.1-mini with two ai_tools: Get a message in Gmail (confirm amounts) and Call Gmail Search (check for usage signals via sub-workflow)\n- **6. Code \u2014 Parse and Validate** strips markdown from AI output, validates JSON, recalculates Est. Annual Cost, validates date format, routes low-confidence items to review\n- **7. Google Sheets \u2014 Upsert Dashboard** upserts 18 columns to the Dashboard sheet matching on Vendor \u2014 updates existing or adds new rows\n- **8. Code \u2014 Build HTML Report** aggregates all rows, computes waste totals, builds a branded HTML email with stat boxes and flagged subscription table\n- **9. Gmail \u2014 Send Weekly Report** sends the HTML digest to your email address\n\n## Sub-workflow (bottom of canvas)\nThe sub-workflow receives Gmail search queries from the AI Agent tool call, executes them via Gmail, and returns results. Replace `YOUR_SUBWORKFLOW_ID` in the Call Gmail Search tool node.\n\n## Set up steps\n1. In **2, 4, 9** \u2014 connect your Gmail OAuth2 credential and replace credential IDs\n2. In **OpenAI \u2014 GPT-4.1-mini Model** \u2014 connect your OpenAI API credential\n3. In **7. Google Sheets \u2014 Upsert Dashboard** \u2014 connect Google Sheets OAuth2 credential and replace `YOUR_GOOGLE_SHEET_ID`. Create a Dashboard sheet tab with columns: Vendor, Category, Amount, Currency, Billing Cycle, Monthly Equivalent, Renewal Date, Days to Renewal, Status, Waste Type, Est. Annual Cost, Priority, Recommended Action, Confidence, First Seen, Last Updated, Source Email\n4. In **9. Gmail \u2014 Send Weekly Report** \u2014 replace `REPLACE_WITH_YOUR_EMAIL@example.com`\n5. In **Call Gmail Search** tool node \u2014 replace `YOUR_SUBWORKFLOW_ID` with the ID of the sub-workflow (copy from browser URL when viewing the sub-workflow)\n6. In the sub-workflow Gmail node \u2014 connect the same Gmail credential"
      },
      "typeVersion": 1
    },
    {
      "id": "8750f9ec-2f56-495e-b729-2ee99921e7e5",
      "name": "Section \u2014 Schedule, Gmail Search, Noise Filter, and Body Fetch",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1616,
        -16
      ],
      "parameters": {
        "color": 5,
        "width": 932,
        "height": 356,
        "content": "## Schedule, Gmail Search, Noise Filter, and Body Fetch\nSchedule triggers the scan. Gmail searches last 30 days for billing and subscription emails. Code prefilters marketing noise \u2014 keeps only real billing signals. Gmail fetches the full body for each filtered message."
      },
      "typeVersion": 1
    },
    {
      "id": "c91ff474-18bb-4260-b442-84946a6ffa06",
      "name": "Section \u2014 AI Agent 5-Step Subscription Extraction and Waste Analysis",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -592,
        -144
      ],
      "parameters": {
        "color": 6,
        "width": 516,
        "height": 708,
        "content": "## AI Agent \u2014 5-Step Subscription Extraction and Waste Analysis\nGPT-4.1-mini runs: classify billing vs non-billing, extract 18 structured fields, optionally call Gmail tools to confirm amounts and check usage signals, classify waste type, compute confidence and route to dashboard or review. Two ai_tools: Get a message in Gmail and Call Gmail Search sub-workflow."
      },
      "typeVersion": 1
    },
    {
      "id": "b579dfab-56ff-4054-a2b2-66304194c25f",
      "name": "Section \u2014 JSON Parse, Validate, and Sheets Upsert",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        64,
        -80
      ],
      "parameters": {
        "color": 6,
        "width": 468,
        "height": 404,
        "content": "## JSON Parse, Validate, and Sheets Upsert\nStrips markdown from AI output, validates JSON, recalculates annual cost, validates date format, routes low-confidence items. Upserts to Google Sheets Dashboard matching on Vendor key."
      },
      "typeVersion": 1
    },
    {
      "id": "4033a8fc-a457-4705-87b4-844a719a23a3",
      "name": "Section \u2014 HTML Report Build and Gmail Send",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        608,
        -64
      ],
      "parameters": {
        "color": 4,
        "width": 532,
        "height": 356,
        "content": "## HTML Report Build and Gmail Send\nAggregates all upserted rows, computes waste totals, builds branded HTML email with stat boxes and flagged subscription table sorted by annual cost. Gmail sends the digest."
      },
      "typeVersion": 1
    },
    {
      "id": "6bdf40b0-8139-41ce-a45b-4251d678fdca",
      "name": "Section \u2014 Gmail Search Sub-Workflow",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1456,
        528
      ],
      "parameters": {
        "color": 5,
        "width": 660,
        "height": 356,
        "content": "## Gmail Search Sub-Workflow\nThis sub-workflow is called by the AI Agent tool when it needs to search Gmail for usage signals or confirm billing details. The trigger receives the search query from the parent workflow and returns Gmail results."
      },
      "typeVersion": 1
    },
    {
      "id": "77369faf-8000-4545-9d86-0c3fc9179c2a",
      "name": "1. Schedule \u2014 Monthly Billing Scan",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -1552,
        80
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "months",
              "triggerAtHour": 9
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "b5b56126-6582-4b19-9fa5-4a0ca038e4e4",
      "name": "2. Gmail \u2014 Search Billing and Usage Emails",
      "type": "n8n-nodes-base.gmail",
      "maxTries": 3,
      "position": [
        -1264,
        80
      ],
      "parameters": {
        "limit": 500,
        "filters": {
          "q": "newer_than:30d (subject:(invoice OR receipt OR subscription OR renewal OR payment OR billing) OR from:(billing OR noreply OR no-reply OR invoice OR receipts) OR subject:(\"we miss you\" OR \"haven't seen you\" OR \"come back\" OR reactivate OR \"your account is inactive\"))"
        },
        "operation": "getAll"
      },
      "retryOnFail": true,
      "typeVersion": 2.1,
      "waitBetweenTries": 5000
    },
    {
      "id": "9c916829-9aa5-4474-8511-05fd726f6f16",
      "name": "3. Code \u2014 Prefilter Noise",
      "type": "n8n-nodes-base.code",
      "position": [
        -1056,
        80
      ],
      "parameters": {
        "jsCode": "const drop=['unsubscribe','newsletter','% off','sale ends','webinar','blog post','digest','password reset','verify your email','sign in to','log in to'];\nconst keep=['invoice','receipt','subscription','renew','payment','billing','charged','your plan','plan renews','auto-renew','order confirmation','your subscription'];\nconst out=[];\nfor(const it of $input.all()){const j=it.json;const s=((j.Subject||j.subject||'')+' '+(j.snippet||'')+' '+(j.From||j.from||'')).toLowerCase();\nif(keep.some(k=>s.includes(k))&&!drop.some(d=>s.includes(d)))out.push({json:j});}\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "62a42b37-f00d-4593-9566-6b0f14db8b46",
      "name": "4. Gmail \u2014 Get Email Body",
      "type": "n8n-nodes-base.gmail",
      "maxTries": 3,
      "position": [
        -800,
        80
      ],
      "parameters": {
        "messageId": "={{ $json.id }}",
        "operation": "get"
      },
      "retryOnFail": true,
      "typeVersion": 2.1,
      "waitBetweenTries": 5000
    },
    {
      "id": "1d454f69-94c3-445a-8d7c-3cb2b6540c5f",
      "name": "5. AI Agent \u2014 Extract and Analyze Subscription",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        -496,
        80
      ],
      "parameters": {
        "text": "=Today is {{ $now.toFormat('yyyy-MM-dd') }}.\nYou are a subscription billing analyst. Your job: analyze the email below, confirm it using Gmail search if needed, then return ONE raw JSON object that maps directly to a Google Sheet row.\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nPRIMARY EMAIL\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nFROM    : {{ $json.From || $json.from || '' }}\nSUBJECT : {{ $json.Subject || $json.subject || '' }}\nDATE    : {{ $json.date || $json.internalDate || '' }}\nBODY    : {{ String($json.text || $json.snippet || '').slice(0, 3500) }}\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nSTEP 1 \u2014 QUICK CLASSIFY\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nIs this a real recurring paid charge (invoice, receipt, renewal notice, subscription confirmation, failed payment, trial-ending charge warning)?\n\nNon-billing = newsletters, marketing, \"X% off\" promos, OTPs, social notifications, password resets.\nIf clearly non-billing \u2192 go directly to OUTPUT with is_billing=false. Skip all other steps.\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nSTEP 2 \u2014 EXTRACT CORE FIELDS\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nFrom the primary email extract:\n- vendor      \u2014 clean company/product name (not email domain)\n- category    \u2014 one of: CRM, Design, Dev Tools, Marketing, Storage, Email, Hosting, Analytics, Project Mgmt, Finance, HR, Security, Communication, Other\n- amount      \u2014 numeric only, no currency symbols. null if not found.\n- currency    \u2014 ISO code: USD, EUR, GBP, INR, etc. Default USD if unclear.\n- billing_cycle \u2014 monthly | annual | quarterly | weekly | one-time | unknown\n- renewal_date \u2014 YYYY-MM-DD from any mention of \"next billing\", \"renews on\", \"charged on\", \"valid until\". null if not found.\n\nCompute:\n- monthly_equivalent:\n    annual    \u2192 amount \u00f7 12\n    quarterly \u2192 amount \u00f7 3\n    weekly    \u2192 amount \u00d7 4.33\n    monthly / one-time \u2192 amount as-is\n    null amount \u2192 0\n- days_to_renewal \u2014 whole integer days from today ({{ $now.toFormat('yyyy-MM-dd') }}) to renewal_date. null if renewal_date is null.\n- est_annual_cost = monthly_equivalent \u00d7 12\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nSTEP 3 \u2014 CONFIRM WITH GMAIL TOOLS (do this when unsure)\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nUse tools when:\na) Amount is unclear \u2192 search \"from:[sender domain] invoice receipt\"\nb) Renewal date is missing \u2192 search \"from:[sender domain] subscription renews\"\nc) You want to check if this tool is being actively used \u2192 search \"from:[sender domain] newer_than:90d\" \u2014 if only billing emails show up and NO login/activity/usage emails exist, mark as potential ZOMBIE\nd) Usage-signal check \u2192 search \"from:[sender domain] (we miss you OR haven't seen you OR come back OR your account is inactive OR reactivate)\" \u2014 if found, mark as UNUSED\n\nAfter each search, use \"Get a message in Gmail\" on the 1-2 most relevant message IDs to read full body for date/amount confirmation.\nMaximum 4 tool calls total \u2014 be efficient.\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nSTEP 4 \u2014 WASTE TYPE + PRIORITY\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nApply the FIRST matching rule (multiple can apply \u2014 use the most severe):\n\n| Rule            | Condition                                                        | waste_type       | Priority |\n|-----------------|------------------------------------------------------------------|------------------|----------|\n| Unused          | Step 3 found re-engagement / \"we miss you\" email from vendor     | UNUSED           | High     |\n| Zombie          | Step 3: only billing emails in 90d, zero activity emails found   | ZOMBIE           | High     |\n| Annual Renewal  | billing_cycle=annual AND days_to_renewal is 0-30                 | ANNUAL_RENEWAL   | High     |\n| Renewal Soon    | days_to_renewal is 0-30 (any cycle)                              | RENEWAL_SOON     | Medium (High if annual) |\n| Healthy         | None of the above                                                | OK               | Low      |\n\nrecommended_action \u2014 one short sentence per type:\n- UNUSED         \u2192 \"Cancel \u2014 no recent activity detected\"\n- ZOMBIE         \u2192 \"Verify usage \u2014 no non-billing emails in 90 days\"\n- ANNUAL_RENEWAL \u2192 \"Review before [renewal_date] \u2014 annual charge incoming\"\n- RENEWAL_SOON   \u2192 \"Review before [renewal_date] \u2014 renews soon\"\n- OK             \u2192 \"Active subscription \u2014 monitor next cycle\"\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nSTEP 5 \u2014 CONFIDENCE + ROUTING\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nStart with AI's own confidence (0.0-1.0).\nThen reduce:\n- amount is null     \u2192 -0.15\n- renewal_date null  \u2192 -0.10\n- vendor unclear     \u2192 -0.15\n- parse from snippet only (no full body) \u2192 -0.10\n\n_target logic:\n- is_billing=true  AND confidence >= 0.6  \u2192 \"_target\": \"dashboard\"\n- is_billing=true  AND confidence < 0.6  \u2192 \"_target\": \"review\"\n- is_billing=false                       \u2192 \"_target\": \"skip\"\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nOUTPUT \u2014 STRICT RULES\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n- Return ONLY a raw JSON object \u2014 zero markdown, zero backticks, zero explanation\n- JSON keys must EXACTLY match the names below (spaces, capitalization identical)\n- Never invent amount or date \u2014 use null\n- All number fields must be numbers, not strings\n\n{\n  \"is_billing\": true,\n  \"Vendor\": \"string\",\n  \"Category\": \"string\",\n  \"Amount\": 0.00,\n  \"Currency\": \"USD\",\n  \"Billing Cycle\": \"monthly | annual | quarterly | weekly | one-time | unknown\",\n  \"Monthly Equivalent\": 0.00,\n  \"Renewal Date\": \"YYYY-MM-DD or null\",\n  \"Days to Renewal\": 0,\n  \"Status\": \"new\",\n  \"Waste Type\": \"OK | UNUSED | ZOMBIE | ANNUAL_RENEWAL | RENEWAL_SOON\",\n  \"Est. Annual Cost\": 0.00,\n  \"Priority\": \"High | Medium | Low\",\n  \"Recommended Action\": \"short action string\",\n  \"Confidence\": 0.00,\n  \"First Seen\": \"{{ $now.toFormat('yyyy-MM-dd') }}\",\n  \"Last Updated\": \"{{ $now.toFormat('yyyy-MM-dd') }}\",\n  \"Source Email\": \"{{ $json.From || $json.from || '' }}\",\n  \"_target\": \"dashboard | review | skip\"\n}",
        "options": {},
        "promptType": "=define"
      },
      "typeVersion": 1.7
    },
    {
      "id": "07c3639a-178d-4141-87b3-837c6bb492b8",
      "name": "OpenAI \u2014 GPT-4.1-mini Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        -496,
        304
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-mini",
          "cachedResultName": "gpt-4.1-mini"
        },
        "options": {}
      },
      "typeVersion": 1.2
    },
    {
      "id": "d99f47e3-7dbe-4018-853f-f5189a4154ac",
      "name": "Get a message in Gmail",
      "type": "n8n-nodes-base.gmailTool",
      "position": [
        -192,
        352
      ],
      "parameters": {
        "messageId": "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('Message_ID', ``, 'string') }}",
        "operation": "get"
      },
      "typeVersion": 2.2
    },
    {
      "id": "85ac205d-a100-462d-a344-6028e3014d4f",
      "name": "Call Gmail Search",
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "position": [
        -352,
        384
      ],
      "parameters": {
        "workflowId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SUBWORKFLOW_ID",
          "cachedResultUrl": "/workflow/YOUR_SUBWORKFLOW_ID",
          "cachedResultName": "Gmail Search Sub-Workflow"
        },
        "workflowInputs": {
          "value": {},
          "schema": [
            {
              "id": "Gmail Search",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Gmail Search",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "Gmail Search"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "a2c5c314-0a36-47a7-aba8-39be7dbd3c5c",
      "name": "6. Code \u2014 Parse and Validate",
      "type": "n8n-nodes-base.code",
      "position": [
        96,
        80
      ],
      "parameters": {
        "jsCode": "// Agent ka output text mein aata hai \u2014 parse karo\nconst raw = $input.first().json.output || $input.first().json.text || '';\n\nlet parsed;\ntry {\n  const clean = raw.replace(/```json|```/gi, '').trim();\n  parsed = JSON.parse(clean);\n} catch(e) {\n  return [{ json: {\n    is_billing: true,\n    Vendor: 'Parse Failed',\n    _target: 'review',\n    _parse_error: e.message,\n    _raw: raw.slice(0, 500)\n  }}];\n}\n\nif (!parsed.is_billing) {\n  return [{ json: { ...parsed, _target: 'skip' } }];\n}\n\nconst amount = Number(parsed.Amount);\nconst me = Number(parsed['Monthly Equivalent']);\nconst conf = Number(parsed.Confidence);\n\nif (isNaN(amount) || amount < 0) parsed.Amount = null;\nif (isNaN(me) || me < 0)         parsed['Monthly Equivalent'] = 0;\n\nparsed['Est. Annual Cost'] = Math.round((isNaN(me) ? 0 : me) * 12 * 100) / 100;\n\nif (parsed['Renewal Date'] && !/^\\d{4}-\\d{2}-\\d{2}$/.test(parsed['Renewal Date'])) {\n  parsed['Renewal Date'] = null;\n  parsed['Days to Renewal'] = null;\n}\n\nif (isNaN(conf) || conf < 0.6) {\n  parsed._target = 'review';\n}\n\nparsed.Status = parsed.Status || 'new';\n\nreturn [{ json: parsed }];"
      },
      "typeVersion": 2
    },
    {
      "id": "251a0fc0-80eb-4c4d-b09f-b20eb3d06a09",
      "name": "7. Google Sheets \u2014 Upsert Dashboard",
      "type": "n8n-nodes-base.googleSheets",
      "maxTries": 3,
      "position": [
        352,
        80
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "Vendor",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Vendor",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Category",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Category",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Amount",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Amount",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Currency",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Currency",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Billing Cycle",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Billing Cycle",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Monthly Equivalent",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Monthly Equivalent",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Renewal Date",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Renewal Date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Days to Renewal",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Days to Renewal",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Status",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Waste Type",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Waste Type",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Est. Annual Cost",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Est. Annual Cost",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Priority",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Priority",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Recommended Action",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Recommended Action",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Confidence",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Confidence",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "First Seen",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "First Seen",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Last Updated",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Last Updated",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Source Email",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Source Email",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [
            "Vendor"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        },
        "options": {},
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "0",
          "cachedResultName": "Dashboard"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID",
          "cachedResultName": "SaaS Waste Auditor"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.5,
      "waitBetweenTries": 5000
    },
    {
      "id": "0671e6d3-78bf-41e1-9bf3-b4b2758f6462",
      "name": "8. Code \u2014 Build HTML Report",
      "type": "n8n-nodes-base.code",
      "position": [
        656,
        80
      ],
      "parameters": {
        "jsCode": "// \u2500\u2500 Rows from Upsert Dashboard (this run) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst all = $input.all()\n  .map(x => x.json)\n  .filter(r => r.Vendor && r.Vendor !== 'Parse Failed');\n\n// \u2500\u2500 Aggregate numbers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet totalMonthly = 0, totalAnnual = 0, wasteAnnual = 0, unused = 0, renew30 = 0;\nconst flagged = [];\n\nfor (const r of all) {\n  const me  = Number(r['Monthly Equivalent'] || 0);\n  const ann = Number(r['Est. Annual Cost']   || me * 12 || 0);\n  totalMonthly += me;\n  totalAnnual  += ann;\n\n  const wt = (r['Waste Type'] || 'OK').toUpperCase();\n  if (wt !== 'OK' && wt !== '') { wasteAnnual += ann; flagged.push(r); }\n  if (wt === 'UNUSED' || wt === 'ZOMBIE') unused++;\n\n  const dtr = Number(r['Days to Renewal']);\n  if (!isNaN(dtr) && dtr >= 0 && dtr <= 30) renew30++;\n}\n\nflagged.sort((a, b) =>\n  Number(b['Est. Annual Cost'] || 0) - Number(a['Est. Annual Cost'] || 0)\n);\n\nconst cur   = 'USD';\nconst brand = 'YOUR_BRAND_NAME';\nconst top   = flagged.slice(0, 15);\n\nconst fmt = n => Number(Math.round(n * 100) / 100).toLocaleString('en-US');\n\nconst badgeColor = wt => {\n  if (wt === 'ANNUAL_RENEWAL') return '#dc2626';\n  if (wt === 'RENEWAL_SOON')   return '#d97706';\n  if (wt === 'UNUSED')         return '#7c3aed';\n  if (wt === 'ZOMBIE')         return '#6b21a8';\n  return '#6b7280';\n};\n\nconst tableRows = top.map(r => {\n  const wt = (r['Waste Type'] || 'OK').toUpperCase();\n  return `\n  <tr>\n    <td style=\"padding:8px 12px;border-bottom:1px solid #f1f5f9;font-weight:500\">${r.Vendor || '\u2014'}</td>\n    <td style=\"padding:8px 12px;border-bottom:1px solid #f1f5f9;color:#64748b\">${r.Category || '\u2014'}</td>\n    <td style=\"padding:8px 12px;border-bottom:1px solid #f1f5f9\">\n      <span style=\"display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:700;color:#fff;background:${badgeColor(wt)}\">${wt}</span>\n    </td>\n    <td style=\"padding:8px 12px;border-bottom:1px solid #f1f5f9;font-weight:600;color:#0f172a\">${cur} ${fmt(Number(r['Est. Annual Cost'] || 0))}/yr</td>\n    <td style=\"padding:8px 12px;border-bottom:1px solid #f1f5f9;font-size:13px;color:#475569\">${r['Recommended Action'] || '\u2014'}</td>\n    <td style=\"padding:8px 12px;border-bottom:1px solid #f1f5f9;font-size:12px;color:#94a3b8\">${r['Renewal Date'] || '\u2014'}</td>\n  </tr>`;\n}).join('');\n\nconst statBox = (label, value, color) =>\n  `<td style=\"width:25%;padding:8px;text-align:center\">\n    <div style=\"background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:14px 8px\">\n      <div style=\"font-size:22px;font-weight:800;color:${color}\">${value}</div>\n      <div style=\"font-size:11px;color:#64748b;margin-top:4px\">${label}</div>\n    </div>\n  </td>`;\n\nconst html = `\n<div style=\"font-family:Arial,sans-serif;max-width:680px;margin:auto;color:#1e293b;background:#f8fafc;padding:20px\">\n  <div style=\"background:#0f172a;color:#fff;padding:24px 28px;border-radius:12px 12px 0 0\">\n    <div style=\"font-size:11px;opacity:.6;letter-spacing:.06em;text-transform:uppercase\">\n      ${brand} &middot; Weekly SaaS Waste Audit &middot; ${new Date().toLocaleDateString('en-US',{month:'long',day:'numeric',year:'numeric'})}\n    </div>\n    <div style=\"font-size:28px;font-weight:800;margin-top:8px;line-height:1.2\">\n      ${cur} ${fmt(wasteAnnual)}<span style=\"font-size:15px;font-weight:400;opacity:.7\"> /year potentially recoverable</span>\n    </div>\n    <div style=\"font-size:13px;opacity:.65;margin-top:6px\">\n      ${all.length} subscriptions tracked &nbsp;&bull;&nbsp; Total spend ${cur} ${fmt(totalAnnual)}/yr &nbsp;&bull;&nbsp; ${flagged.length} flagged\n    </div>\n  </div>\n  <table style=\"width:100%;border-collapse:separate;border-spacing:8px;background:#f1f5f9;padding:12px\">\n    <tr>\n      ${statBox('Total Subscriptions', all.length, '#0f172a')}\n      ${statBox('Flagged Issues', flagged.length, '#dc2626')}\n      ${statBox('Renewing in 30d', renew30, '#d97706')}\n      ${statBox('Possibly Unused', unused, '#7c3aed')}\n    </tr>\n  </table>\n  <div style=\"background:#fff;border:1px solid #e2e8f0;border-radius:0 0 12px 12px;padding:24px\">\n    <p style=\"margin:0 0 18px;font-size:14px;color:#334155\">\n      Out of <b>${all.length}</b> subscriptions costing <b>${cur} ${fmt(totalMonthly)}/month</b>\n      (${cur} ${fmt(totalAnnual)}/year), we flagged <b>${flagged.length} item(s)</b> worth\n      <b>${cur} ${fmt(wasteAnnual)}/year</b> in potential waste.\n      ${renew30 > 0 ? `<b>${renew30}</b> subscription(s) renew within 30 days \u2014 review before auto-charge.` : ''}\n    </p>\n    ${top.length > 0 ? `\n    <table style=\"border-collapse:collapse;width:100%;font-size:13px\">\n      <thead>\n        <tr style=\"background:#f8fafc;text-align:left;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.05em\">\n          <th style=\"padding:10px 12px;border-bottom:2px solid #e2e8f0\">Vendor</th>\n          <th style=\"padding:10px 12px;border-bottom:2px solid #e2e8f0\">Category</th>\n          <th style=\"padding:10px 12px;border-bottom:2px solid #e2e8f0\">Issue</th>\n          <th style=\"padding:10px 12px;border-bottom:2px solid #e2e8f0\">Annual Cost</th>\n          <th style=\"padding:10px 12px;border-bottom:2px solid #e2e8f0\">Action</th>\n          <th style=\"padding:10px 12px;border-bottom:2px solid #e2e8f0\">Renewal</th>\n        </tr>\n      </thead>\n      <tbody>${tableRows}</tbody>\n    </table>` : '<p style=\"color:#94a3b8;font-style:italic;font-size:13px\">No flagged items this week \u2014 all subscriptions look healthy.</p>'}\n    <div style=\"margin-top:16px;padding:12px 16px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;font-size:13px\">\n      <span style=\"margin-right:24px\">Monthly: ${cur} ${fmt(totalMonthly)}</span>\n      <span style=\"margin-right:24px\">Annual: ${cur} ${fmt(totalAnnual)}</span>\n      <span>Recoverable: ${cur} ${fmt(wasteAnnual)}</span>\n    </div>\n    <p style=\"font-size:11px;color:#94a3b8;margin-top:16px;border-top:1px solid #f1f5f9;padding-top:12px\">\n      Email-only detection. Charges billed directly to a card without email confirmation are not captured here.\n      Auto-generated on ${new Date().toISOString().split('T')[0]}.\n    </p>\n  </div>\n</div>`;\n\nreturn [{\n  json: {\n    subject : `${brand} SaaS Audit \u2014 ${cur} ${fmt(wasteAnnual)}/yr recoverable \u00b7 ${all.length} subs`,\n    html    : html,\n    stats   : {\n      totalMonthly : Math.round(totalMonthly * 100) / 100,\n      totalAnnual  : Math.round(totalAnnual  * 100) / 100,\n      wasteAnnual  : Math.round(wasteAnnual  * 100) / 100,\n      count        : all.length,\n      flaggedCount : flagged.length,\n      renew30,\n      unused\n    }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "25e87002-e917-4af2-8a47-c7e60ce6cd9b",
      "name": "9. Gmail \u2014 Send Weekly Report",
      "type": "n8n-nodes-base.gmail",
      "maxTries": 3,
      "position": [
        944,
        80
      ],
      "parameters": {
        "sendTo": "user@example.com",
        "message": "={{ $json.html }}",
        "options": {
          "appendAttribution": false
        },
        "subject": "={{ $json.subject }}"
      },
      "retryOnFail": true,
      "typeVersion": 2.1,
      "waitBetweenTries": 5000
    },
    {
      "id": "2c7e4246-344d-49e5-a5c0-27ddde406bbf",
      "name": "When Executed by Another Workflow",
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "position": [
        -1264,
        672
      ],
      "parameters": {
        "workflowInputs": {
          "values": [
            {
              "name": "Gmail Search",
              "type": "any"
            }
          ]
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "899eebdb-d9cf-4243-98b5-50a428528fb2",
      "name": "Gmail Search",
      "type": "n8n-nodes-base.gmail",
      "maxTries": 3,
      "position": [
        -1056,
        672
      ],
      "parameters": {
        "limit": 100,
        "filters": {
          "q": "={{ $json['Gmail Search'] }}"
        },
        "operation": "getAll"
      },
      "retryOnFail": true,
      "typeVersion": 2.1,
      "waitBetweenTries": 5000
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "4014c11f-2ad1-42a3-87de-2df954d2a1a1",
  "connections": {
    "Call Gmail Search": {
      "ai_tool": [
        [
          {
            "node": "5. AI Agent \u2014 Extract and Analyze Subscription",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Get a message in Gmail": {
      "ai_tool": [
        [
          {
            "node": "5. AI Agent \u2014 Extract and Analyze Subscription",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "3. Code \u2014 Prefilter Noise": {
      "main": [
        [
          {
            "node": "4. Gmail \u2014 Get Email Body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "4. Gmail \u2014 Get Email Body": {
      "main": [
        [
          {
            "node": "5. AI Agent \u2014 Extract and Analyze Subscription",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "8. Code \u2014 Build HTML Report": {
      "main": [
        [
          {
            "node": "9. Gmail \u2014 Send Weekly Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI \u2014 GPT-4.1-mini Model": {
      "ai_languageModel": [
        [
          {
            "node": "5. AI Agent \u2014 Extract and Analyze Subscription",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "6. Code \u2014 Parse and Validate": {
      "main": [
        [
          {
            "node": "7. Google Sheets \u2014 Upsert Dashboard",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Executed by Another Workflow": {
      "main": [
        [
          {
            "node": "Gmail Search",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1. Schedule \u2014 Monthly Billing Scan": {
      "main": [
        [
          {
            "node": "2. Gmail \u2014 Search Billing and Usage Emails",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "7. Google Sheets \u2014 Upsert Dashboard": {
      "main": [
        [
          {
            "node": "8. Code \u2014 Build HTML Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "2. Gmail \u2014 Search Billing and Usage Emails": {
      "main": [
        [
          {
            "node": "3. Code \u2014 Prefilter Noise",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "5. AI Agent \u2014 Extract and Analyze Subscription": {
      "main": [
        [
          {
            "node": "6. Code \u2014 Parse and Validate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}