{
  "name": "Reconcile daily CSV exports in Google Drive into a deduped master with a reject lane and Slack recap",
  "tags": [],
  "nodes": [
    {
      "name": "Run on Schedule",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        0,
        192
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 6 * * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "name": "List CSV Files in Folder",
      "type": "n8n-nodes-base.googleDrive",
      "maxTries": 3,
      "position": [
        208,
        192
      ],
      "parameters": {
        "filter": {
          "folderId": {
            "__rl": true,
            "mode": "list",
            "value": ""
          },
          "whatToSearch": "files"
        },
        "options": {
          "fields": [
            "id",
            "name",
            "mimeType"
          ]
        },
        "resource": "fileFolder",
        "operation": "search",
        "returnAll": true,
        "queryString": "name contains '.csv' and not name contains 'reconciled-'",
        "searchMethod": "query",
        "authentication": "oAuth2"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 3,
      "waitBetweenTries": 5000
    },
    {
      "name": "Download Each CSV",
      "type": "n8n-nodes-base.googleDrive",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        416,
        192
      ],
      "parameters": {
        "fileId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.id }}"
        },
        "options": {
          "binaryPropertyName": "data"
        },
        "resource": "file",
        "operation": "download",
        "authentication": "oAuth2"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 3,
      "alwaysOutputData": true,
      "waitBetweenTries": 5000
    },
    {
      "name": "Parse CSV Rows",
      "type": "n8n-nodes-base.extractFromFile",
      "onError": "continueRegularOutput",
      "position": [
        624,
        192
      ],
      "parameters": {
        "options": {
          "headerRow": true
        },
        "operation": "csv",
        "binaryPropertyName": "data"
      },
      "typeVersion": 1.1
    },
    {
      "name": "Reconcile CSVs",
      "type": "n8n-nodes-base.code",
      "position": [
        992,
        224
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// =====================================================================\n// EDIT HERE: reconciliation rules (the only block most people change)\n// =====================================================================\n// Every row must have these columns present and non-empty. Change them to\n// match your export. The dedup key below must be one of these columns.\nconst REQUIRED_COLUMNS = ['order_id', 'branch', 'sku', 'qty', 'amount', 'order_date'];\n\n// Rows are deduplicated on this column. The first row wins; later rows with\n// the same value are sent to the reject file as duplicates.\nconst KEY_COLUMN = 'order_id';\n\n// Light format checks: 'integer', 'number', or 'date' (YYYY-MM-DD). A column\n// not listed here is only checked for being present and non-empty.\nconst COLUMN_TYPES = { qty: 'integer', amount: 'number', order_date: 'date' };\n\n// Output files are named <prefix>-master-YYYY-MM-DD.csv and\n// <prefix>-rejects-YYYY-MM-DD.csv. The List node skips this prefix, so the\n// outputs are never read back in on the next run.\nconst OUTPUT_PREFIX = 'reconciled';\n// =====================================================================\n// No need to edit below this line.\n// =====================================================================\n\nfunction pairedIndex(it) {\n  const p = it && it.pairedItem;\n  if (p == null) return null;\n  if (typeof p === 'number') return p;\n  if (Array.isArray(p)) return (p[0] && typeof p[0].item === 'number') ? p[0].item : null;\n  return (typeof p.item === 'number') ? p.item : null;\n}\nfunction fileNameFor(it, downloadItems) {\n  try {\n    const idx = pairedIndex(it);\n    if (idx == null) return 'unknown';\n    const d = downloadItems[idx];\n    const n = d && d.json && (d.json.name || d.json.fileName);\n    return n ? String(n) : 'unknown';\n  } catch (e) {\n    return 'unknown';\n  }\n}\nfunction isEmpty(v) {\n  return v === undefined || v === null || String(v).trim() === '';\n}\nfunction checkType(val, type) {\n  const s = String(val).trim();\n  if (type === 'integer') return /^-?\\d+$/.test(s);\n  if (type === 'number') return s !== '' && !isNaN(Number(s)) && isFinite(Number(s));\n  if (type === 'date') {\n    if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return false;\n    const d = new Date(s + 'T00:00:00Z');\n    return !isNaN(d.getTime());\n  }\n  return true;\n}\n// Required columns first (in config order), then any other original columns,\n// then the audit columns last so the reason sits at the end of each reject row.\nfunction orderedRow(row, extra) {\n  const out = {};\n  for (const c of REQUIRED_COLUMNS) out[c] = (row[c] !== undefined ? row[c] : '');\n  for (const k of Object.keys(row)) if (!(k in out)) out[k] = row[k];\n  if (extra) for (const k of Object.keys(extra)) out[k] = extra[k];\n  return out;\n}\n\nconst parsedItems = $input.all();\nconst downloadItems = $('Download Each CSV').all();\nconst fileCount = $('List CSV Files in Folder').all().length;\n\nconst now = new Date();\nconst runDate = now.getUTCFullYear() + '-' +\n  String(now.getUTCMonth() + 1).padStart(2, '0') + '-' +\n  String(now.getUTCDate()).padStart(2, '0');\n\nconst master = [];\nconst rejects = [];\nconst seen = new Map(); // key value -> source file of the first kept row\nlet rowsIn = 0;\nlet quarantined = 0;\nlet duplicatesDropped = 0;\nlet unreadableFiles = 0;\n\nfor (const it of parsedItems) {\n  const json = (it && it.json) ? it.json : {};\n  const keys = Object.keys(json);\n\n  // An Extract from File error item (routed back to the main lane) looks like\n  // { error: '<message>' } and nothing else: a file that could not be parsed.\n  const isErrorItem = (typeof json.error === 'string') && keys.length === 1;\n  if (isErrorItem) {\n    unreadableFiles++;\n    rejects.push(orderedRow({}, {\n      source_file: fileNameFor(it, downloadItems),\n      reject_type: 'unreadable_file',\n      reject_reason: 'File could not be parsed as CSV: ' + json.error,\n    }));\n    continue;\n  }\n\n  rowsIn++;\n  const srcFile = fileNameFor(it, downloadItems);\n\n  // ---- validation ----\n  const reasons = [];\n  for (const col of REQUIRED_COLUMNS) {\n    if (isEmpty(json[col])) reasons.push('missing or empty required column \"' + col + '\"');\n  }\n  if (isEmpty(json[KEY_COLUMN]) && !reasons.some(r => r.indexOf('\"' + KEY_COLUMN + '\"') !== -1)) {\n    reasons.push('empty key column \"' + KEY_COLUMN + '\"');\n  }\n  for (const col of Object.keys(COLUMN_TYPES)) {\n    if (!isEmpty(json[col]) && !checkType(json[col], COLUMN_TYPES[col])) {\n      reasons.push('column \"' + col + '\" is not a valid ' + COLUMN_TYPES[col] + ' (got \"' + String(json[col]) + '\")');\n    }\n  }\n\n  if (reasons.length) {\n    quarantined++;\n    rejects.push(orderedRow(json, {\n      source_file: srcFile,\n      reject_type: 'invalid',\n      reject_reason: reasons.join('; '),\n    }));\n    continue;\n  }\n\n  // ---- dedup on the key column, keeping the first occurrence ----\n  const keyVal = String(json[KEY_COLUMN]).trim();\n  if (seen.has(keyVal)) {\n    duplicatesDropped++;\n    rejects.push(orderedRow(json, {\n      source_file: srcFile,\n      reject_type: 'duplicate',\n      reject_reason: 'duplicate ' + KEY_COLUMN + ' \"' + keyVal + '\" (earlier row kept from ' + seen.get(keyVal) + ')',\n    }));\n    continue;\n  }\n  seen.set(keyVal, srcFile);\n  master.push(orderedRow(json, null));\n}\n\nconst merged = master.length;\nconst masterFileName = OUTPUT_PREFIX + '-master-' + runDate + '.csv';\nconst rejectFileName = OUTPUT_PREFIX + '-rejects-' + runDate + '.csv';\n\nconst lines = [];\nlines.push('CSV Folder Reconciler run (' + runDate + ')');\nlines.push('Files read: ' + fileCount);\nlines.push('Rows in: ' + rowsIn);\nlines.push('Merged to master: ' + merged);\nlines.push('Quarantined (invalid): ' + quarantined);\nlines.push('Duplicates dropped: ' + duplicatesDropped);\nif (unreadableFiles > 0) lines.push('Unreadable files: ' + unreadableFiles);\nlines.push(merged > 0\n  ? 'Master file: ' + masterFileName\n  : 'Master file: none written (no valid rows)');\nlines.push(rejects.length > 0\n  ? 'Reject file: ' + rejectFileName + ' (' + rejects.length + ' rows)'\n  : 'Reject file: none written (nothing quarantined)');\nconst recapText = lines.join('\\n');\n\nconst recap = {\n  runDate,\n  filesRead: fileCount,\n  rowsIn,\n  merged,\n  quarantined,\n  duplicatesDropped,\n  unreadableFiles,\n  rejectRows: rejects.length,\n  recapText,\n};\n\nreturn [{ json: { recap, master, rejects, masterFileName, rejectFileName, hasMaster: merged > 0, hasRejects: rejects.length > 0 } }];\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "name": "Master Rows",
      "type": "n8n-nodes-base.code",
      "position": [
        1392,
        144
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Expand the deduplicated master rows into one item per row for the CSV builder.\n// No items means no valid rows, so the master CSV and its upload are skipped.\nreturn ($input.first().json.master || []).map((json) => ({ json }));\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "name": "Build Master CSV",
      "type": "n8n-nodes-base.convertToFile",
      "position": [
        1568,
        144
      ],
      "parameters": {
        "options": {
          "fileName": "={{ $('Reconcile CSVs').first().json.masterFileName }}",
          "headerRow": true
        },
        "operation": "csv",
        "binaryPropertyName": "data"
      },
      "typeVersion": 1.1
    },
    {
      "name": "Upload Master CSV",
      "type": "n8n-nodes-base.googleDrive",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        1760,
        144
      ],
      "parameters": {
        "name": "={{ $('Reconcile CSVs').first().json.masterFileName }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "options": {},
        "folderId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "resource": "file",
        "operation": "upload",
        "authentication": "oAuth2",
        "inputDataFieldName": "data"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 3,
      "waitBetweenTries": 5000
    },
    {
      "name": "Reject Rows",
      "type": "n8n-nodes-base.code",
      "position": [
        1392,
        496
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Expand the quarantined rows (invalid, duplicate, and unreadable files) into\n// one item per row for the reject CSV builder. No items means nothing was\n// quarantined, so the reject CSV and its upload are skipped this run.\nreturn ($input.first().json.rejects || []).map((json) => ({ json }));\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "name": "Build Reject CSV",
      "type": "n8n-nodes-base.convertToFile",
      "position": [
        1568,
        496
      ],
      "parameters": {
        "options": {
          "fileName": "={{ $('Reconcile CSVs').first().json.rejectFileName }}",
          "headerRow": true
        },
        "operation": "csv",
        "binaryPropertyName": "data"
      },
      "typeVersion": 1.1
    },
    {
      "name": "Upload Reject CSV",
      "type": "n8n-nodes-base.googleDrive",
      "onError": "continueRegularOutput",
      "maxTries": 3,
      "position": [
        1760,
        496
      ],
      "parameters": {
        "name": "={{ $('Reconcile CSVs').first().json.rejectFileName }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "options": {},
        "folderId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "resource": "file",
        "operation": "upload",
        "authentication": "oAuth2",
        "inputDataFieldName": "data"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 3,
      "waitBetweenTries": 5000
    },
    {
      "name": "Post Recap to Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        1392,
        688
      ],
      "parameters": {
        "text": "={{ $json.recap.recapText }}",
        "select": "channel",
        "resource": "message",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "operation": "post",
        "messageType": "text",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5
    },
    {
      "name": "Sticky Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -560,
        -160
      ],
      "parameters": {
        "color": 1,
        "width": 480,
        "height": 880,
        "content": "## CSV Folder Reconciler\n\nMerges the daily CSV exports that land in one Google Drive folder into a single deduplicated master file, quarantines every bad row to a dated reject file with a reason, and posts an auditable recap to Slack. Built for multi-branch retail or finance teams whose branches each drop a daily export. No AI, fully deterministic.\n\n### How it works\n1. A schedule fires once a day.\n2. Google Drive lists every CSV in the folder and downloads each one.\n3. Extract from File parses each CSV into rows.\n4. The Reconcile node merges all rows, validates each against your rules, deduplicates on a key, and splits good rows from bad.\n5. Convert to File writes a clean master CSV and a dated reject CSV, both uploaded back to Drive.\n6. Slack receives the rows in, merged, quarantined, and duplicates recap.\n\n### Setup\n1. Connect a Google Drive credential to the four Drive nodes and a Slack credential to the recap node.\n2. In \"List CSV Files in Folder\", pick the folder your exports land in.\n3. In \"Reconcile CSVs\", set the required columns, the dedup key, and the format checks.\n4. In the two upload nodes, pick the folder for the master and reject files.\n5. In \"Post Recap to Slack\", pick the channel.\n6. Run once, then activate."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Read",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -48,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 856,
        "height": 334,
        "content": "## 1. Read the folder\nLists every CSV in the folder (skipping the reconciled- prefix so outputs are never re-read), downloads each, and parses it into rows. A file that will not parse is logged and skipped."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Reconcile",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        832,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 452,
        "height": 366,
        "content": "## 2. Validate, dedup, quarantine\nEach row needs every required column filled, a non-empty key, and valid formats (integer, number, date). Bad rows are quarantined with a reason. Survivors are deduped on the key, keeping the first. Edit the rules at the top of this node."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Master",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1312,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 648,
        "height": 298,
        "content": "## 3a. Clean master CSV\nThe good, deduped rows become reconciled-master-YYYY-MM-DD.csv, uploaded to your output folder. Valid rows only, no audit columns added."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Rejects",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1312,
        304
      ],
      "parameters": {
        "color": 7,
        "width": 648,
        "height": 346,
        "content": "## 3b. Reject lane (quarantine)\nQuarantined rows become reconciled-rejects-YYYY-MM-DD.csv with source_file, reject_type (invalid, duplicate, unreadable_file), and reject_reason columns. Skipped when nothing is quarantined."
      },
      "typeVersion": 1
    },
    {
      "name": "Sticky Folder Safety",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1312,
        -208
      ],
      "parameters": {
        "color": 5,
        "width": 640,
        "height": 188,
        "content": "## Keep outputs out of the input\nThe master and reject files use the reconciled- prefix and the List node skips that prefix, so writing them to the same folder is safe. To be extra safe, point the two upload nodes at a separate output folder."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Master Rows": {
      "main": [
        [
          {
            "node": "Build Master CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reject Rows": {
      "main": [
        [
          {
            "node": "Build Reject CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse CSV Rows": {
      "main": [
        [
          {
            "node": "Reconcile CSVs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reconcile CSVs": {
      "main": [
        [
          {
            "node": "Master Rows",
            "type": "main",
            "index": 0
          },
          {
            "node": "Reject Rows",
            "type": "main",
            "index": 0
          },
          {
            "node": "Post Recap to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run on Schedule": {
      "main": [
        [
          {
            "node": "List CSV Files in Folder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Master CSV": {
      "main": [
        [
          {
            "node": "Upload Master CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Reject CSV": {
      "main": [
        [
          {
            "node": "Upload Reject CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download Each CSV": {
      "main": [
        [
          {
            "node": "Parse CSV Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List CSV Files in Folder": {
      "main": [
        [
          {
            "node": "Download Each CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}