{
  "name": "einvoice-daily-reconcile \u2014 Schedule \u2192 query yesterday \u2192 diff \u2192 finance summary",
  "nodes": [
    {
      "parameters": {
        "content": "## einvoice-daily-reconcile (scheduler) \u2014 v0.28.0 hardened\n\n**v0.28.0 fixes (from V&V findings)**\n- workflow settings.timezone = `Asia/Taipei` so 02:00 fires in Taipei time AND `new Date()` in Code nodes uses Taipei-local intent. The yesterday calculation is now done with a Taipei locale string instead of UTC ISO slice that was off-by-one at 02:00 Taipei.\n- HTTP node uses `fullResponse: true` so `statusCode` and `body` exist.\n- The README-promised \"finance summary email\" \u2014 actually shipped as the `Email finance` node at the end of the diff branch.\n\n**Trigger**: Schedule 02:00 (Asia/Taipei).\n\n**Flow**\n1. Calculate yesterday in Asia/Taipei (handles DST n/a + tz drift correctly).\n2. Read Audit Sheet rows; filter to op=issue + appliedAt prefixed with yesterday + status=issued.\n3. Call svc /v1/query per candidate.\n4. Diff against expected `ISSUED` state.\n5. Mismatches \u2192 Mismatches tab + Slack alert.\n6. Always send finance daily summary email with green/mismatch counts.\n\n**Before activating**: replace `__REPLACE_ME__EINVOICE_SVC_URL__`, `__REPLACE_ME__GOOGLE_SHEET_ID__`, `__REPLACE_ME__SLACK_CHANNEL__`, `__REPLACE_ME__FINANCE_EMAIL__`.\n\n---\n\n## \u4e2d\u6587 \u2014 einvoice-daily-reconcile\uff08\u6838\u5fc3 / \u6392\u7a0b\uff09\n\n**v0.28.0 \u4fee\u6b63\u7d00\u9304**\uff1a\n- workflow settings.timezone = `Asia/Taipei`\uff0c\u51cc\u6668 02:00 \u5728\u53f0\u5317\u6642\u9593 fire\uff1bCode \u7bc0\u9ede\u7684\u65e5\u671f\u904b\u7b97\u6539\u7528 `Intl.DateTimeFormat` + \u53f0\u5317 locale\uff0c\u907f\u514d UTC slice \u5728\u53f0\u5317 02:00 \u7b97\u5230\u300c\u524d\u5929\u300d\u7684\u9677\u9631\u3002\n- HTTP \u7bc0\u9ede `fullResponse: true`\uff0c`statusCode` \u771f\u7684\u5b58\u5728\u3002\n- README \u627f\u8afe\u7684\u300c\u8ca1\u52d9\u65e5\u5831 email\u300d\u5be6\u969b\u505a\u51fa\u4f86 \u2014 \u5728 diff \u5206\u652f\u672b\u7aef `Email finance` \u7bc0\u9ede\u3002\n\n**v0.32.0 \u52a0 twin-node**\uff1a`[TEST] Read Audit` Code \u7bc0\u9ede\u70ba sibling\uff0c\u9810\u8a2d disabled\u3002runtime smoke \u6642 toggle \u5169\u500b disabled flag \u5373\u53ef\u5728\u6c92\u6709 Google Sheets credential \u4e0b\u8dd1\u5b8c\u3002\n\n**\u89f8\u767c**\uff1aSchedule 02:00\uff08Asia/Taipei\uff09\u3002\n\n**\u6d41\u7a0b**\uff1a\n1. \u5728\u53f0\u5317\u6642\u5340\u7b97\u51fa\u6628\u5929\uff08\u8655\u7406 DST \u4e0d\u9069\u7528 + tz \u6f02\u79fb\uff09\u3002\n2. \u8b80 Audit Sheet \u5217\uff1bfilter \u51fa op=issue + appliedAt \u958b\u982d\u662f\u6628\u5929 + status=issued\u3002\n3. \u5c0d\u6bcf\u7b46\u547c svc /v1/query\u3002\n4. \u8ddf\u671f\u671b `ISSUED` \u72c0\u614b\u6bd4\u5c0d\u3002\n5. \u4e0d\u4e00\u81f4 \u2192 \u5beb Mismatches tab + Slack \u8b66\u793a\u3002\n6. \u4e00\u5f8b\u5bc4\u8ca1\u52d9\u65e5\u5831 email\uff08\u542b\u7da0 / \u4e0d\u4e00\u81f4\u7b46\u6578\uff09\u3002\n\n**\u4e0a\u7dda\u524d\u9700\u66ff\u63db**\uff1a`__REPLACE_ME__EINVOICE_SVC_URL__`\u3001`__REPLACE_ME__GOOGLE_SHEET_ID__`\u3001`__REPLACE_ME__SLACK_CHANNEL__`\u3001`__REPLACE_ME__FINANCE_EMAIL__`",
        "height": 900,
        "width": 580
      },
      "id": "note-1",
      "name": "Pattern note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -740,
        -380
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "triggerAtHour": 2
            }
          ]
        }
      },
      "id": "sched-1",
      "name": "Daily 02:00 Taipei",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.1,
      "position": [
        -120,
        -40
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Compute yesterday in Asia/Taipei robustly (Intl handles tz, no UTC slice trap).\nconst now = new Date();\nconst taipei = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Taipei', year: 'numeric', month: '2-digit', day: '2-digit' });\nconst todayParts = taipei.formatToParts(now).reduce((acc,p) => { if (p.type !== 'literal') acc[p.type] = p.value; return acc; }, {});\nconst todayTaipei = `${todayParts.year}-${todayParts.month}-${todayParts.day}`;\n// One day back in Taipei = subtract 24h from the Taipei-formatted today.\nconst y = new Date(todayTaipei + 'T00:00:00+08:00');\ny.setUTCDate(y.getUTCDate() - 1);\nconst yParts = taipei.formatToParts(y).reduce((acc,p) => { if (p.type !== 'literal') acc[p.type] = p.value; return acc; }, {});\nconst yesterday = `${yParts.year}-${yParts.month}-${yParts.day}`;\nreturn [{ json: { correlationId: `recon-${yesterday}-${Math.floor(Math.random()*1e6)}`, yesterday, todayTaipei } }];"
      },
      "id": "stamp-1",
      "name": "Stamp yesterday (Taipei)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        120,
        -40
      ]
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": "__REPLACE_ME__GOOGLE_SHEET_ID__",
        "sheetName": "Audit"
      },
      "id": "read-1",
      "name": "Read Audit",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        360,
        -40
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// [TEST] Twin-node test injection per code2n8n-pipeline SKILL \u00a71.7.\n// Toggle THIS node disabled=false AND the sibling 'Read Audit' disabled=true\n// to run runtime smoke without a Google Sheets credential.\n// Schema MUST match what 'Read Audit' would return (a list of items shaped\n// like a previously-written 'Audit' tab row).\nreturn [\n  { json: { correlationId: 'fake-yest-001', op: 'issue', provider: 'amego', orderId: 'order-yest-001', invoiceNumber: 'AA00000001', status: 'issued', result: JSON.stringify({ invoiceNumber: 'AA00000001' }), appliedAt: '2026-06-18T03:15:00.000Z' } },\n  { json: { correlationId: 'fake-yest-002', op: 'issue', provider: 'amego', orderId: 'order-yest-002', invoiceNumber: 'AA00000002', status: 'issued', result: JSON.stringify({ invoiceNumber: 'AA00000002' }), appliedAt: '2026-06-18T09:42:00.000Z' } },\n  { json: { correlationId: 'fake-yest-003', op: 'issue', provider: 'amego', orderId: 'order-yest-003', invoiceNumber: 'AA00000999', status: 'issued', result: JSON.stringify({ invoiceNumber: 'AA00000999' }), appliedAt: '2026-06-18T14:20:00.000Z' } }\n];"
      },
      "id": "read-test-1",
      "name": "[TEST] Read Audit",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        360,
        80
      ],
      "disabled": true
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const yesterday = $node['Stamp yesterday (Taipei)'].json.yesterday;\nconst correlationId = $node['Stamp yesterday (Taipei)'].json.correlationId;\nconst rows = $items().map(i => i.json);\nconst candidates = rows.filter(r => r.op === 'issue' && (r.appliedAt || '').startsWith(yesterday) && r.status === 'issued');\nif (candidates.length === 0) return [{ json: { correlationId, yesterday, empty: true, total: 0, green: 0, mismatchRows: [] } }];\nreturn candidates.map(json => ({ json: { ...json, correlationId, yesterday } }));"
      },
      "id": "filter-1",
      "name": "Filter to yesterday",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        600,
        -40
      ]
    },
    {
      "parameters": {
        "url": "=__REPLACE_ME__EINVOICE_SVC_URL__/v1/query",
        "method": "POST",
        "sendBody": true,
        "contentType": "json",
        "jsonBody": "={{ { provider: $json.provider, input: { invoiceNumber: (JSON.parse($json.result || '{}').invoiceNumber) || null, orderId: $json.orderId } } }}",
        "options": {
          "timeout": 15000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        },
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "specifyBody": "json"
      },
      "id": "query-1",
      "name": "Query provider",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [
        840,
        -40
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Collapse all queries into one summary item.\nconst items = $items().map(i => i.json);\nconst correlationId = $node['Stamp yesterday (Taipei)'].json.correlationId;\nconst yesterday = $node['Stamp yesterday (Taipei)'].json.yesterday;\nconst mismatchRows = [];\nlet green = 0;\nfor (const it of items) {\n  const statusCode = Number(it.statusCode ?? 0);\n  const body = it.body ?? it;\n  const expected = 'ISSUED';\n  const actual = body?.result?.status ?? (statusCode >= 200 && statusCode < 300 ? 'UNKNOWN' : 'NETWORK_OR_ERROR');\n  if (statusCode >= 200 && statusCode < 300 && actual === expected) green++;\n  else mismatchRows.push({ orderId: it.orderId, provider: it.provider, invoiceNumber: body?.result?.invoiceNumber ?? null, expected, actual });\n}\nreturn [{ json: { correlationId, yesterday, total: items.length, green, mismatch: mismatchRows.length, mismatchRows } }];"
      },
      "id": "diff-1",
      "name": "Summarize diffs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1080,
        -40
      ]
    },
    {
      "parameters": {
        "conditions": {
          "number": [
            {
              "value1": "={{ $json.mismatch }}",
              "operation": "larger",
              "value2": 0
            }
          ]
        }
      },
      "id": "if-1",
      "name": "Any mismatch?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        1320,
        -40
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Fan out mismatches into one item each for Sheet append.\nconst d = $json;\nreturn (d.mismatchRows || []).map(m => ({ json: { correlationId: d.correlationId, yesterday: d.yesterday, ...m, detectedAt: new Date().toISOString() } }));"
      },
      "id": "fanout-1",
      "name": "Fan out mismatches",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1560,
        -120
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": "__REPLACE_ME__GOOGLE_SHEET_ID__",
        "sheetName": "Mismatches",
        "columnsUi": {
          "columnValues": [
            {
              "column": "correlationId",
              "value": "={{ $json.correlationId }}"
            },
            {
              "column": "orderId",
              "value": "={{ $json.orderId }}"
            },
            {
              "column": "provider",
              "value": "={{ $json.provider }}"
            },
            {
              "column": "invoiceNumber",
              "value": "={{ $json.invoiceNumber }}"
            },
            {
              "column": "expected",
              "value": "={{ $json.expected }}"
            },
            {
              "column": "actual",
              "value": "={{ $json.actual }}"
            },
            {
              "column": "detectedAt",
              "value": "={{ $json.detectedAt }}"
            }
          ]
        }
      },
      "id": "write-mis-1",
      "name": "Append mismatches",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        1800,
        -120
      ]
    },
    {
      "parameters": {
        "channel": "__REPLACE_ME__SLACK_CHANNEL__",
        "text": "=\ud83d\udcc9 einvoice reconcile \u00b7 {{ $node['Summarize diffs'].json.yesterday }}\\n{{ $node['Summarize diffs'].json.mismatch }} mismatch / {{ $node['Summarize diffs'].json.total }} total \u00b7 correlation `{{ $node['Summarize diffs'].json.correlationId }}`"
      },
      "id": "slack-mis-1",
      "name": "Slack mismatch alert",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        2040,
        -120
      ]
    },
    {
      "parameters": {
        "fromEmail": "noreply@your-company.tw",
        "toEmail": "__REPLACE_ME__FINANCE_EMAIL__",
        "subject": "=einvoice \u65e5\u5c0d\u5e33 {{ $node['Summarize diffs'].json.yesterday }} \u00b7 {{ $node['Summarize diffs'].json.mismatch }} \u4e0d\u4e00\u81f4 / {{ $node['Summarize diffs'].json.total }} \u7b46",
        "text": "=correlationId: {{ $node['Summarize diffs'].json.correlationId }}\\n\u65e5\u671f: {{ $node['Summarize diffs'].json.yesterday }}\\n\u7e3d\u8a08: {{ $node['Summarize diffs'].json.total }} \u7b46\\n\u901a\u904e: {{ $node['Summarize diffs'].json.green }} \u7b46\\n\u4e0d\u4e00\u81f4: {{ $node['Summarize diffs'].json.mismatch }} \u7b46\\n\\n\u8a73\u898b Google Sheet Mismatches tab\u3002",
        "options": {}
      },
      "id": "email-1",
      "name": "Email finance",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        2280,
        -40
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "return [{ json: { ok: true, summary: $node['Summarize diffs'].json } }];"
      },
      "id": "noop-1",
      "name": "All green \u2014 proceed",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1560,
        80
      ]
    }
  ],
  "connections": {
    "Daily 02:00 Taipei": {
      "main": [
        [
          {
            "node": "Stamp yesterday (Taipei)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stamp yesterday (Taipei)": {
      "main": [
        [
          {
            "node": "Read Audit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Audit": {
      "main": [
        [
          {
            "node": "Filter to yesterday",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "[TEST] Read Audit": {
      "main": [
        [
          {
            "node": "Filter to yesterday",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter to yesterday": {
      "main": [
        [
          {
            "node": "Query provider",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Query provider": {
      "main": [
        [
          {
            "node": "Summarize diffs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Summarize diffs": {
      "main": [
        [
          {
            "node": "Any mismatch?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any mismatch?": {
      "main": [
        [
          {
            "node": "Fan out mismatches",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "All green \u2014 proceed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fan out mismatches": {
      "main": [
        [
          {
            "node": "Append mismatches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append mismatches": {
      "main": [
        [
          {
            "node": "Slack mismatch alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack mismatch alert": {
      "main": [
        [
          {
            "node": "Email finance",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "All green \u2014 proceed": {
      "main": [
        [
          {
            "node": "Email finance",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Asia/Taipei"
  },
  "active": false,
  "tags": []
}