{
  "name": "einvoice-void-with-approval \u2014 Slack approval gate \u2192 svc/v1/void",
  "nodes": [
    {
      "parameters": {
        "content": "## einvoice-void-with-approval (entry) \u2014 v0.28.0 hardened\n\n**v0.28.0 fixes (from V&V findings)**\n- `$resumeUrl` is NOT a real n8n variable. Corrected to `$execution.resumeUrl`.\n- State (correlationId / provider / input / requestedBy / reason) is restamped AFTER Wait via `$node['Stamp correlation'].json` (Wait drops upstream state by default).\n- Webhook responseMode = `responseNode` (fixed JSON schema, no internal leak).\n- HTTP node uses `fullResponse: true` so audit row's `status` checks real statusCode.\n- Asia/Taipei timezone set.\n- SEC-9 carryover: approver identity STILL comes from the resume webhook caller (n8n public-API limit); for production put HMAC verifier (e.g. Slack `X-Slack-Signature`) in front of the resume URL \u2014 see SECURITY-REVIEW.md.\n\n**Why void needs a human gate**: void is irreversible. Approver owns the audit trail.\n\n**Trigger**: webhook POST `/webhook/einvoice/void`.\n```json\n{ \"provider\": \"amego\", \"requestedBy\": \"jane@company.tw\", \"reason\": \"\u5ba2\u6236\u53d6\u6d88\u8a02\u55ae\", \"input\": { \"invoiceNumber\": \"AB12345678\", \"reason\": \"\u5ba2\u6236\u53d6\u6d88\" } }\n```\n\n**Before activating**: replace `__REPLACE_ME__EINVOICE_SVC_URL__`, `__REPLACE_ME__SLACK_CHANNEL__`, `__REPLACE_ME__GOOGLE_SHEET_ID__`.\n\n---\n\n## \u4e2d\u6587 \u2014 einvoice-void-with-approval\uff08\u5165\u53e3 / \u4f5c\u5ee2\u4eba\u5de5\u6838\u53ef\uff09\n\n**\u70ba\u4f55\u9700\u8981\u4eba\u5de5\u6838\u53ef**\uff1a\u4f5c\u5ee2\u4e0d\u53ef\u9006\u3002\u6309 approve \u7684\u4eba\u5c0d audit \u8ca0\u8cac\u3002\u5efa\u69cb\u5728 `examples/templates/human-approval-gate.workflow.json` \u4e4b\u4e0a\u3002\n\n**\u89f8\u767c**\uff1aCS / \u7ba1\u7406 UI \u6253 `POST /webhook/einvoice/void`\u3002\n\n**Body**\uff1a\u898b\u4e0a\u65b9 JSON \u7bc4\u4f8b\u3002\n\n**\u6d41\u7a0b**\uff1a\n1. \u84cb correlationId\u3001\u767c Slack \u8a0a\u606f\u542b `$execution.resumeUrl/approve` \u548c `$execution.resumeUrl/reject`\u3002\n2. Wait \u6700\u591a 4 \u5c0f\u6642\u3002\n3. \u6536\u5230 approve \u2192 \u6253 svc/v1/void \u2192 \u5beb audit row\u3002\n4. \u6536\u5230 reject \u6216 timeout \u2192 \u53ea\u5beb audit row\u3001\u4e0d\u6253 svc\u3002\n\n**\ud83d\udd12 \u4e0a\u7dda\u524d\u786c\u5316**\uff1a\n- Wait \u7bc0\u9ede\u63a5\u53d7\u4efb\u4f55 POST \u5230 `$execution.resumeUrl/approve` \u2014 \u4e0a\u7dda\u7248\u524d\u9762\u5fc5\u9808\u52a0 reverse proxy \u6216 inbound Webhook + HMAC-SHA256 \u9a57\u7c3d\uff08`X-Slack-Signature` \u6216\u4f60 IdP \u7b49\u50f9\u7269\uff09\u3002\u8a73\u898b `skills/tigerai/n8n-security-governance` \u00a76\u3002\n- audit row \u7684 `approver` \u6b04\u4f4d**\u4e0d\u53ef\u4fe1\u4efb caller \u81ea\u5831**\uff0c\u5fc5\u9808\u4f86\u81ea Slack \u9a57\u7c3d\u904e\u7684 user \u8b58\u5225\u3002\n\n**\u4e0a\u7dda\u524d\u9700\u66ff\u63db**\uff1a`__REPLACE_ME__EINVOICE_SVC_URL__`\u3001`__REPLACE_ME__SLACK_CHANNEL__`\uff08**\u4e0d\u8981 #general\uff0c\u8acb\u7528 #einvoice-ops \u7b49\u53ef\u5be9\u67e5 channel**\uff09\u3001`__REPLACE_ME__GOOGLE_SHEET_ID__`",
        "height": 900,
        "width": 580
      },
      "id": "note-1",
      "name": "Pattern note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -740,
        -400
      ]
    },
    {
      "parameters": {
        "path": "einvoice/void",
        "httpMethod": "POST",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "wh-1",
      "name": "Webhook: void request",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1.1,
      "position": [
        -120,
        -40
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const body = $json.body || $json;\nfor (const k of ['provider','input','requestedBy','reason']) if (!body[k]) throw new Error(`${k} required`);\nreturn [{ json: {\n  correlationId: `inv-void-${Date.now()}-${Math.floor(Math.random()*1e6)}`,\n  requestedAt: new Date().toISOString(),\n  provider: body.provider,\n  requestedBy: body.requestedBy,\n  reason: body.reason,\n  input: body.input\n}}];"
      },
      "id": "stamp-1",
      "name": "Stamp correlation",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        120,
        -40
      ]
    },
    {
      "parameters": {
        "channel": "__REPLACE_ME__SLACK_CHANNEL__",
        "text": "=\ud83d\uded1 *VOID approval required*\\nInvoice: `{{ $json.input?.invoiceNumber }}` (provider: {{ $json.provider }})\\nRequested by: {{ $json.requestedBy }}\\nReason: {{ $json.reason }}\\nCorrelation: `{{ $json.correlationId }}`\\n\\nApprove: {{ $execution.resumeUrl }}?decision=approve\\nReject: {{ $execution.resumeUrl }}?decision=reject\\n\\n\u26a0 Void is irreversible. The approver field in the audit row is taken from this resume URL caller \u2014 in production, front it with HMAC verification."
      },
      "id": "slack-1",
      "name": "Notify approver",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        360,
        -40
      ]
    },
    {
      "parameters": {
        "resume": "webhook",
        "options": {
          "responseTimeout": "4h"
        }
      },
      "id": "wait-1",
      "name": "Wait for approval",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1,
      "position": [
        600,
        -40
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// After Wait, $json is the resume callback payload. Rehydrate the upstream state.\nconst state = $node['Stamp correlation'].json;\nconst decision = $json.query?.decision || $json.body?.decision || 'reject';\nconst approver = $json.query?.approver || $json.body?.approver || 'unknown';\nreturn [{ json: { ...state, decision, approver, decidedAt: new Date().toISOString() } }];"
      },
      "id": "rehydrate-1",
      "name": "Rehydrate after Wait",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        840,
        -40
      ]
    },
    {
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{ $json.decision }}",
              "operation": "equals",
              "value2": "approve"
            }
          ]
        }
      },
      "id": "if-1",
      "name": "Approved?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        1080,
        -40
      ]
    },
    {
      "parameters": {
        "url": "=__REPLACE_ME__EINVOICE_SVC_URL__/v1/void",
        "method": "POST",
        "sendBody": true,
        "contentType": "json",
        "jsonBody": "={{ { provider: $json.provider, input: $json.input } }}",
        "options": {
          "timeout": 15000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        },
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "specifyBody": "json"
      },
      "id": "http-void-1",
      "name": "Call svc /v1/void",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [
        1320,
        -160
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const state = $node['Rehydrate after Wait'].json;\nconst statusCode = Number($json.statusCode ?? 0);\nconst ok = statusCode >= 200 && statusCode < 300;\nreturn [{ json: { ...state, statusCode, ok, response: $json.body ?? $json } }];"
      },
      "id": "merge-1",
      "name": "Rehydrate after void",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1560,
        -160
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": "__REPLACE_ME__GOOGLE_SHEET_ID__",
        "sheetName": "Audit",
        "columnsUi": {
          "columnValues": [
            {
              "column": "correlationId",
              "value": "={{ $json.correlationId }}"
            },
            {
              "column": "op",
              "value": "void"
            },
            {
              "column": "provider",
              "value": "={{ $json.provider }}"
            },
            {
              "column": "invoiceNumber",
              "value": "={{ $json.input?.invoiceNumber }}"
            },
            {
              "column": "requestedBy",
              "value": "={{ $json.requestedBy }}"
            },
            {
              "column": "approver",
              "value": "={{ $json.approver }}"
            },
            {
              "column": "reason",
              "value": "={{ $json.reason }}"
            },
            {
              "column": "status",
              "value": "={{ $json.ok ? 'voided' : 'failed' }}"
            },
            {
              "column": "appliedAt",
              "value": "={{ $json.decidedAt }}"
            }
          ]
        }
      },
      "id": "audit-ok-1",
      "name": "Audit (approved)",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        1800,
        -160
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": "__REPLACE_ME__GOOGLE_SHEET_ID__",
        "sheetName": "Audit",
        "columnsUi": {
          "columnValues": [
            {
              "column": "correlationId",
              "value": "={{ $json.correlationId }}"
            },
            {
              "column": "op",
              "value": "void"
            },
            {
              "column": "provider",
              "value": "={{ $json.provider }}"
            },
            {
              "column": "invoiceNumber",
              "value": "={{ $json.input?.invoiceNumber }}"
            },
            {
              "column": "requestedBy",
              "value": "={{ $json.requestedBy }}"
            },
            {
              "column": "approver",
              "value": "={{ $json.approver }}"
            },
            {
              "column": "reason",
              "value": "={{ $json.reason }}"
            },
            {
              "column": "status",
              "value": "rejected"
            },
            {
              "column": "appliedAt",
              "value": "={{ $json.decidedAt }}"
            }
          ]
        }
      },
      "id": "audit-rej-1",
      "name": "Audit (rejected)",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        1320,
        80
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: !!$json.ok, correlationId: $json.correlationId, decision: $json.decision ?? 'rejected', status: $json.decision === 'approve' ? ($json.ok ? 'voided' : 'failed') : 'rejected' }) }}",
        "options": {}
      },
      "id": "respond-1",
      "name": "Respond to caller",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2040,
        -80
      ]
    }
  ],
  "connections": {
    "Webhook: void request": {
      "main": [
        [
          {
            "node": "Stamp correlation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stamp correlation": {
      "main": [
        [
          {
            "node": "Notify approver",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Notify approver": {
      "main": [
        [
          {
            "node": "Wait for approval",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait for approval": {
      "main": [
        [
          {
            "node": "Rehydrate after Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rehydrate after Wait": {
      "main": [
        [
          {
            "node": "Approved?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Approved?": {
      "main": [
        [
          {
            "node": "Call svc /v1/void",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Audit (rejected)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Call svc /v1/void": {
      "main": [
        [
          {
            "node": "Rehydrate after void",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rehydrate after void": {
      "main": [
        [
          {
            "node": "Audit (approved)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Audit (approved)": {
      "main": [
        [
          {
            "node": "Respond to caller",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Audit (rejected)": {
      "main": [
        [
          {
            "node": "Respond to caller",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Asia/Taipei"
  },
  "active": false,
  "tags": []
}