{
  "name": "Stripe L5: Daily reconciliation",
  "nodes": [
    {
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.4,
      "position": [-460, 0],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 2
            }
          ]
        }
      }
    },
    {
      "name": "Get balance transactions",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [-220, 0],
      "parameters": {
        "method": "GET",
        "url": "https://api.stripe.com/v1/balance_transactions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "created[gte]",
              "value": "={{ Math.floor($now.setZone('UTC').startOf('day').minus({ days: 1 }).toSeconds()) }}"
            },
            {
              "name": "created[lt]",
              "value": "={{ Math.floor($now.setZone('UTC').startOf('day').toSeconds()) }}"
            },
            {
              "name": "limit",
              "value": "100"
            }
          ]
        },
        "options": {
          "pagination": {
            "pagination": {
              "paginationMode": "updateAParameterInEachRequest",
              "parameters": {
                "parameters": [
                  {
                    "type": "qs",
                    "name": "starting_after",
                    "value": "={{ $response.body.data[$response.body.data.length - 1].id }}"
                  }
                ]
              },
              "paginationCompleteWhen": "other",
              "completeExpression": "={{ $response.body.has_more === false }}",
              "limitPagesFetched": false
            }
          }
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_ME",
          "name": "Stripe API"
        }
      }
    },
    {
      "name": "Collect matchable txn ids",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [180, 0],
      "parameters": {
        "jsCode": "// Every page of the paginated response arrives as its own item.\n// Only these balance-transaction types can ever have a matching webhook event:\n// charge.succeeded carries a `charge`/`payment` txn, refund.created a `refund`/`payment_refund` one.\n// Payouts, fees, transfers and adjustments have no event, so counting them as gaps\n// would alert every single day for no reason.\nconst MATCHABLE = ['charge', 'payment', 'refund', 'payment_refund'];\n\nconst txns = [];\nfor (const item of $input.all()) {\n  for (const t of item.json.data ?? []) {\n    if (MATCHABLE.includes(t.type)) txns.push(t);\n  }\n}\n\n// One item per currency: never add two currencies into one total.\nconst byCurrency = {};\nfor (const t of txns) {\n  (byCurrency[t.currency] ??= []).push(t.id);\n}\n\nconst runDate = $now.setZone('UTC').startOf('day').minus({ days: 1 }).toISODate();\nconst rows = Object.entries(byCurrency).map(([currency, ids]) => ({\n  json: { run_date: runDate, currency, ids, stripe_txn_count: ids.length },\n}));\n\n// A quiet day has nothing matchable. Returning [] would stop the workflow here,\n// so stripe_reconciliation_log could not tell that day apart from a broken check.\nreturn rows.length\n  ? rows\n  : [{ json: { run_date: runDate, currency: 'usd', ids: [], stripe_txn_count: 0 } }];"
      }
    },
    {
      "name": "Find unmatched txn ids",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.7,
      "position": [400, 0],
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT count(*)::int AS missing_in_events,\n       coalesce(array_agg(bt.txn_id), '{}') AS missing_ids\nFROM unnest($1::text[]) AS bt(txn_id)\nLEFT JOIN stripe_events e ON e.balance_transaction = bt.txn_id\nWHERE e.id IS NULL;",
        "options": {
          "queryReplacement": "={{ [ '{' + $json.ids.map(id => '\"' + id + '\"').join(',') + '}' ] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "REPLACE_ME",
          "name": "Postgres"
        }
      }
    },
    {
      "name": "Slack: reconciliation gap",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [1060, -100],
      "parameters": {
        "authentication": "accessToken",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#alerts"
        },
        "text": "=Stripe reconciliation gap for {{ $('Collect matchable txn ids').item.json.run_date }} ({{ $('Collect matchable txn ids').item.json.currency }}): {{ $json.missing_in_events }} of {{ $('Collect matchable txn ids').item.json.stripe_txn_count }} balance transactions have no row in stripe_events.",
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "id": "REPLACE_ME",
          "name": "Slack"
        }
      }
    },
    {
      "name": "Write reconciliation log",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.7,
      "position": [620, 0],
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO stripe_reconciliation_log (\n    run_date, currency, stripe_txn_count, matched_count,\n    missing_in_events, unmatched_in_events, notes\n)\nVALUES ($1::date, $2, $3::int, $4::int, $5::int, 0, $6)\nRETURNING id, missing_in_events;",
        "options": {
          "queryReplacement": "={{ [$('Collect matchable txn ids').item.json.run_date, $('Collect matchable txn ids').item.json.currency, $('Collect matchable txn ids').item.json.stripe_txn_count, $('Collect matchable txn ids').item.json.stripe_txn_count - $json.missing_in_events, $json.missing_in_events, ($json.missing_ids ?? []).join(' ')] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "REPLACE_ME",
          "name": "Postgres"
        }
      }
    },
    {
      "name": "Any gap?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [840, 0],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "gap",
              "leftValue": "={{ $json.missing_in_events }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ]
        },
        "options": {}
      }
    }
  ],
  "connections": {
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Get balance transactions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get balance transactions": {
      "main": [
        [
          {
            "node": "Collect matchable txn ids",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect matchable txn ids": {
      "main": [
        [
          {
            "node": "Find unmatched txn ids",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find unmatched txn ids": {
      "main": [
        [
          {
            "node": "Write reconciliation log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write reconciliation log": {
      "main": [
        [
          {
            "node": "Any gap?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any gap?": {
      "main": [
        [
          {
            "node": "Slack: reconciliation gap",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  },
  "settings": {}
}
