{
  "id": "digest-daily",
  "name": "Finance \u2014 Daily digest email",
  "nodes": [
    {
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "position": [
        240,
        300
      ],
      "notes": "Polls every 15 min. Sends one digest per poll for each missed day until caught up. 5 days offline = 5 digests over ~75 minutes."
    },
    {
      "name": "Check if daily digest is due",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT\n  (last_fetched_at AT TIME ZONE 'Asia/Kolkata')::date::text AS period_start,\n  ((last_fetched_at AT TIME ZONE 'Asia/Kolkata')::date + 1)::text AS period_end\nFROM sync_state\nWHERE source = 'digest_daily'\n  AND NOW() >= (((last_fetched_at AT TIME ZONE 'Asia/Kolkata')::date + 1)::timestamp + INTERVAL '9 hours') AT TIME ZONE 'Asia/Kolkata'"
      },
      "position": [
        460,
        300
      ],
      "notes": "last_fetched_at = start of next undigested day (midnight IST). Fires when we're past 9am IST of the day AFTER that \u2014 meaning the period day is fully complete. Returns 0 rows if nothing is due."
    },
    {
      "name": "Query period stats",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT\n  (SELECT count(*) FROM raw_events\n   WHERE ingested_at >= $1::date AT TIME ZONE 'Asia/Kolkata'\n     AND ingested_at < $2::date AT TIME ZONE 'Asia/Kolkata') AS raw_ingested,\n  (SELECT count(*) FROM parsed_transactions\n   WHERE parsed_at >= $1::date AT TIME ZONE 'Asia/Kolkata'\n     AND parsed_at < $2::date AT TIME ZONE 'Asia/Kolkata'\n     AND ebk_status = 'written') AS written_to_ebk,\n  (SELECT count(*) FROM parsed_transactions\n   WHERE parsed_at >= $1::date AT TIME ZONE 'Asia/Kolkata'\n     AND parsed_at < $2::date AT TIME ZONE 'Asia/Kolkata'\n     AND parse_method = 'groq') AS groq_calls,\n  (SELECT count(*) FROM failed_events\n   WHERE failed_at >= $1::date AT TIME ZONE 'Asia/Kolkata'\n     AND failed_at < $2::date AT TIME ZONE 'Asia/Kolkata'\n     AND resolved = FALSE) AS open_failures,\n  (SELECT count(*) FROM dedup_log\n   WHERE first_seen_at >= $1::date AT TIME ZONE 'Asia/Kolkata'\n     AND first_seen_at < $2::date AT TIME ZONE 'Asia/Kolkata') AS deduplicated,\n  (SELECT COALESCE(SUM(amount),0) FROM parsed_transactions\n   WHERE direction = 'debit'\n     AND transaction_time >= date_trunc('month', NOW() AT TIME ZONE 'Asia/Kolkata') AT TIME ZONE 'Asia/Kolkata') AS month_spend",
        "additionalFields": {
          "queryParams": "={{ [$json.period_start, $json.period_end] }}"
        }
      },
      "position": [
        680,
        200
      ]
    },
    {
      "name": "Query open failures",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT fe.id, fe.stage, fe.error_message, re.subject, re.sender, fe.failed_at FROM failed_events fe LEFT JOIN raw_events re ON fe.raw_event_id = re.id WHERE fe.resolved = FALSE ORDER BY fe.failed_at DESC LIMIT 10"
      },
      "position": [
        680,
        400
      ]
    },
    {
      "name": "Query unreviewed GroqCloud",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT id, input_fragment, parsed_fields, called_at FROM groq_parse_log WHERE promoted = FALSE ORDER BY called_at DESC LIMIT 5"
      },
      "position": [
        680,
        600
      ]
    },
    {
      "name": "Build email body",
      "type": "n8n-nodes-base.code",
      "parameters": {
        "language": "javaScript",
        "jsCode": "const period = $('Check if daily digest is due').first().json;\nconst stats = $('Query period stats').first().json;\nconst failures = $('Query open failures').all().map(i => i.json);\nconst groqItems = $('Query unreviewed GroqCloud').all().map(i => i.json);\n\nconst periodDate = new Date(period.period_start + 'T00:00:00+05:30')\n  .toLocaleDateString('en-IN', { dateStyle: 'full' });\n\nlet failureRows = failures.length === 0\n  ? '<tr><td colspan=\"4\" style=\"color:green\">None \u2014 all clear</td></tr>'\n  : failures.map(f =>\n      `<tr>\n        <td>${f.id}</td>\n        <td>${f.stage}</td>\n        <td>${f.subject || '\u2014'}</td>\n        <td>${f.error_message?.substring(0, 80) || '\u2014'}</td>\n      </tr>`\n    ).join('');\n\nlet groqRows = groqItems.length === 0\n  ? '<tr><td colspan=\"3\" style=\"color:green\">None pending</td></tr>'\n  : groqItems.map(g =>\n      `<tr>\n        <td>${g.id}</td>\n        <td>${g.input_fragment?.substring(0, 80) || '\u2014'}</td>\n        <td>${JSON.stringify(g.parsed_fields)?.substring(0, 100) || '\u2014'}</td>\n      </tr>`\n    ).join('');\n\nconst html = `\n<html><body style=\"font-family: sans-serif; color: #222; max-width: 700px; margin: auto\">\n<h2>Finance pipeline \u2014 daily digest</h2>\n<p style=\"color: #666\">${periodDate}</p>\n\n<h3>Activity for ${periodDate}</h3>\n<table border=\"1\" cellpadding=\"6\" cellspacing=\"0\" style=\"border-collapse:collapse; width:100%\">\n  <tr style=\"background:#f0f0f0\"><th>Metric</th><th>Value</th></tr>\n  <tr><td>Emails ingested</td><td>${stats.raw_ingested}</td></tr>\n  <tr><td>Transactions written to ezbookkeeping</td><td>${stats.written_to_ebk}</td></tr>\n  <tr><td>Deduplicated (dropped)</td><td>${stats.deduplicated}</td></tr>\n  <tr><td>GroqCloud fallback calls</td><td>${stats.groq_calls}</td></tr>\n  <tr><td>Open failures</td><td style=\"color:${stats.open_failures > 0 ? 'red' : 'green'}\">${stats.open_failures}</td></tr>\n  <tr><td>Month-to-date spend</td><td>${Number(stats.month_spend).toLocaleString('en-IN', { style: 'currency', currency: 'INR' })}</td></tr>\n</table>\n\n<h3>Open failures (manual review needed)</h3>\n<table border=\"1\" cellpadding=\"6\" cellspacing=\"0\" style=\"border-collapse:collapse; width:100%\">\n  <tr style=\"background:#f0f0f0\"><th>ID</th><th>Stage</th><th>Email subject</th><th>Error</th></tr>\n  ${failureRows}\n</table>\n\n<h3>GroqCloud parses not yet promoted to regex</h3>\n<table border=\"1\" cellpadding=\"6\" cellspacing=\"0\" style=\"border-collapse:collapse; width:100%\">\n  <tr style=\"background:#f0f0f0\"><th>ID</th><th>Input fragment</th><th>Parsed fields</th></tr>\n  ${groqRows}\n</table>\n\n<p style=\"font-size:12px;color:#999\">To mark a failure resolved: <code>UPDATE failed_events SET resolved=TRUE WHERE id=&lt;id&gt;;</code><br>\nTo promote a Groq pattern to regex: see RUNBOOK.md</p>\n</body></html>\n`;\n\nreturn [{ json: { subject: `Finance digest \u2014 ${periodDate}`, html, period_end: period.period_end } }];\n"
      },
      "position": [
        900,
        400
      ]
    },
    {
      "name": "Send email",
      "type": "n8n-nodes-base.gmail",
      "parameters": {
        "operation": "send",
        "sendTo": "={{ $credentials.digestRecipient }}",
        "subject": "={{ $json.subject }}",
        "message": "={{ $json.html }}",
        "options": {
          "bodyContentType": "html"
        }
      },
      "position": [
        1120,
        400
      ]
    },
    {
      "name": "Advance digest cursor",
      "type": "n8n-nodes-base.postgres",
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE sync_state SET last_fetched_at = $1::date AT TIME ZONE 'Asia/Kolkata', updated_at = NOW() WHERE source = 'digest_daily'",
        "additionalFields": {
          "queryParams": "={{ [$('Build email body').first().json.period_end] }}"
        }
      },
      "position": [
        1340,
        400
      ],
      "notes": "Advances last_fetched_at by exactly 1 day (to period_end). Does NOT jump to NOW(). Next poll will check if another day is pending."
    }
  ],
  "connections": {
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Check if daily digest is due",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check if daily digest is due": {
      "main": [
        [
          {
            "node": "Query period stats",
            "type": "main",
            "index": 0
          },
          {
            "node": "Query open failures",
            "type": "main",
            "index": 0
          },
          {
            "node": "Query unreviewed GroqCloud",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Query period stats": {
      "main": [
        [
          {
            "node": "Build email body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Query open failures": {
      "main": [
        [
          {
            "node": "Build email body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Query unreviewed GroqCloud": {
      "main": [
        [
          {
            "node": "Build email body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build email body": {
      "main": [
        [
          {
            "node": "Send email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send email": {
      "main": [
        [
          {
            "node": "Advance digest cursor",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}