{
  "name": "Addendo \u2014 Cost Guard v1",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "cost-guard-v1",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "1a000000-0000-4000-8000-000000000001",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Validate body and compute UTC hour key.\n// _route in {invalid, check, record}.\nconst raw = $input.first().json;\nconst body = (raw && typeof raw === 'object' && raw.body && typeof raw.body === 'object') ? raw.body : raw;\n\nconst errors = [];\nconst mode = body && body.mode;\nif (mode !== 'check' && mode !== 'record') {\n  errors.push(\"mode must be 'check' or 'record'\");\n}\n\nconst workflowId = body && body.workflow_id;\nif (typeof workflowId !== 'string' || workflowId.trim() === '') {\n  errors.push('workflow_id required (non-empty string)');\n}\n\nlet costUsd = 0;\nif (mode === 'record') {\n  costUsd = Number(body.cost_usd);\n  if (!Number.isFinite(costUsd) || costUsd <= 0) {\n    errors.push('cost_usd required (positive number) when mode=record');\n  } else if (costUsd > 100) {\n    errors.push('cost_usd exceeds sanity limit (100 USD)');\n  }\n}\n\nconst d = new Date();\nconst pad = n => String(n).padStart(2, '0');\nconst hourKey = `sec:cost:hourly:${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())}-${pad(d.getUTCHours())}`;\n\nif (errors.length > 0) {\n  return [{\n    json: {\n      _route: 'invalid',\n      _status: 400,\n      error: 'validation_failed',\n      details: errors\n    }\n  }];\n}\n\nreturn [{\n  json: {\n    _route: mode,\n    workflow_id: workflowId.trim(),\n    cost_usd: costUsd,\n    key: hourKey,\n    cap_usd: 3.00\n  }\n}];"
      },
      "id": "1a000000-0000-4000-8000-000000000002",
      "name": "Validate & Build Key",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        240,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-invalid",
              "leftValue": "={{ $json._route }}",
              "rightValue": "invalid",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "1a000000-0000-4000-8000-000000000003",
      "name": "IF invalid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        480,
        0
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { error: $json.error, details: $json.details } }}",
        "options": {
          "responseCode": 400
        }
      },
      "id": "1a000000-0000-4000-8000-000000000004",
      "name": "Respond 400",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        720,
        -200
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "cond-check",
              "leftValue": "={{ $json._route }}",
              "rightValue": "check",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "1a000000-0000-4000-8000-000000000005",
      "name": "IF check?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        720,
        200
      ]
    },
    {
      "parameters": {
        "operation": "get",
        "key": "={{ $json.key }}",
        "options": {}
      },
      "id": "1a000000-0000-4000-8000-000000000006",
      "name": "Redis GET (check)",
      "type": "n8n-nodes-base.redis",
      "typeVersion": 1,
      "position": [
        960,
        100
      ],
      "credentials": {
        "redis": {
          "name": "<your credential>"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// Parse Redis GET output (check mode).\nconst input = $input.first().json;\nconst meta = $('Validate & Build Key').first().json;\nconst key = meta.key;\nconst cap = meta.cap_usd;\n\n// n8n Redis GET returns { propertyName: <value> } when value exists,\n// or { propertyName: null } / empty object when key absent.\n// Discover the value field robustly.\nlet rawValue = null;\nfor (const k of Object.keys(input)) {\n  if (input[k] !== undefined && input[k] !== null && k !== 'key') {\n    rawValue = input[k];\n    break;\n  }\n}\n// Fallback: also handle direct value or 'value' field\nif (rawValue === null && typeof input === 'object') {\n  if ('value' in input) rawValue = input.value;\n  else if (key in input) rawValue = input[key];\n}\n\nconst current = (rawValue === null || rawValue === '' || rawValue === undefined)\n  ? 0.00\n  : Number(rawValue);\n\nif (!Number.isFinite(current)) {\n  console.log(JSON.stringify({\n    level: 'ERROR', component: 'cost-guard-v1',\n    event: 'corrupt_value_failclosed', mode: 'check', key, raw_value: rawValue\n  }));\n  return [{ json: { allow: false, reason: 'redis_corrupt_value_failclosed', key, cap_usd: cap } }];\n}\n\nif (current >= cap) {\n  return [{ json: { allow: false, reason: 'hourly_cap_exceeded', current_usd: current, cap_usd: cap, key } }];\n}\nreturn [{ json: { allow: true, current_usd: current, cap_usd: cap, key } }];"
      },
      "id": "1a000000-0000-4000-8000-000000000007",
      "name": "Parse CHECK",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        100
      ]
    },
    {
      "parameters": {
        "jsCode": "// FAIL-CLOSED: Redis unavailable on check.\nconst meta = $('Validate & Build Key').first().json;\nconsole.log(JSON.stringify({\n  level: 'ERROR', component: 'cost-guard-v1',\n  event: 'redis_unavailable_failclosed', mode: 'check',\n  key: meta.key, error: $input.first().json\n}));\nreturn [{ json: { allow: false, reason: 'redis_unavailable_failclosed', key: meta.key, cap_usd: meta.cap_usd } }];"
      },
      "id": "1a000000-0000-4000-8000-000000000008",
      "name": "Fail-Closed CHECK",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        280
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "1a000000-0000-4000-8000-000000000009",
      "name": "Respond CHECK",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        1440,
        180
      ]
    },
    {
      "parameters": {
        "operation": "get",
        "key": "={{ $json.key }}",
        "options": {}
      },
      "id": "1a000000-0000-4000-8000-00000000000a",
      "name": "Redis GET (record)",
      "type": "n8n-nodes-base.redis",
      "typeVersion": 1,
      "position": [
        960,
        400
      ],
      "credentials": {
        "redis": {
          "name": "<your credential>"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// Compute new total = current + cost. Carry forward all fields needed by next nodes.\nconst input = $input.first().json;\nconst meta = $('Validate & Build Key').first().json;\nconst key = meta.key;\nconst costUsd = meta.cost_usd;\n\nlet rawValue = null;\nfor (const k of Object.keys(input)) {\n  if (input[k] !== undefined && input[k] !== null && k !== 'key') {\n    rawValue = input[k];\n    break;\n  }\n}\nif (rawValue === null && typeof input === 'object') {\n  if ('value' in input) rawValue = input.value;\n  else if (key in input) rawValue = input[key];\n}\n\nconst current = (rawValue === null || rawValue === '' || rawValue === undefined)\n  ? 0.00\n  : Number(rawValue);\n\nif (!Number.isFinite(current)) {\n  // Treat corrupt as fresh (defensive). Log warn.\n  console.log(JSON.stringify({\n    level: 'WARN', component: 'cost-guard-v1',\n    event: 'record_corrupt_existing_value_reset', key, raw_value: rawValue\n  }));\n}\n\nconst safeCurrent = Number.isFinite(current) ? current : 0;\n// Round to 8 decimals to avoid float drift on long sequences\nconst newTotal = Math.round((safeCurrent + costUsd) * 1e8) / 1e8;\n\nreturn [{\n  json: {\n    key,\n    new_total_usd: newTotal,\n    cost_usd_pendiente: costUsd,\n    workflow_id_caller: meta.workflow_id\n  }\n}];"
      },
      "id": "1a000000-0000-4000-8000-00000000000b",
      "name": "Compute New Total",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        380
      ]
    },
    {
      "parameters": {
        "operation": "set",
        "key": "={{ $json.key }}",
        "value": "={{ $json.new_total_usd.toString() }}",
        "keyType": "string",
        "expire": true,
        "ttl": 3600
      },
      "id": "1a000000-0000-4000-8000-00000000000c",
      "name": "Redis SET (record)",
      "type": "n8n-nodes-base.redis",
      "typeVersion": 1,
      "position": [
        1440,
        380
      ],
      "credentials": {
        "redis": {
          "name": "<your credential>"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// Build success response for record.\nconst prev = $('Compute New Total').first().json;\nreturn [{\n  json: {\n    recorded: true,\n    new_total_usd: prev.new_total_usd,\n    key: prev.key,\n    ttl_seconds: 3600\n  }\n}];"
      },
      "id": "1a000000-0000-4000-8000-00000000000d",
      "name": "Build RECORD success",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1680,
        320
      ]
    },
    {
      "parameters": {
        "jsCode": "// FAIL: Redis unavailable on record (GET or SET branch). Structured alert + alert_sent flag.\nconst meta = $('Validate & Build Key').first().json;\nconst alertEvent = {\n  level: 'CRITICAL',\n  component: 'cost-guard-v1',\n  event: 'record_redis_unavailable',\n  mode: 'record',\n  key: meta.key,\n  cost_usd_pendiente: meta.cost_usd,\n  workflow_id_caller: meta.workflow_id,\n  raw_error: $input.first().json,\n  timestamp_utc: new Date().toISOString()\n};\nconsole.log('COST_GUARD_ALERT ' + JSON.stringify(alertEvent));\nreturn [{\n  json: {\n    recorded: false,\n    reason: 'redis_unavailable',\n    cost_usd_pendiente: meta.cost_usd,\n    key: meta.key,\n    alert_sent: true\n  }\n}];"
      },
      "id": "1a000000-0000-4000-8000-00000000000e",
      "name": "Fail-Closed RECORD",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1440,
        580
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "1a000000-0000-4000-8000-00000000000f",
      "name": "Respond RECORD",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        1920,
        480
      ]
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "Validate & Build Key",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate & Build Key": {
      "main": [
        [
          {
            "node": "IF invalid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF invalid?": {
      "main": [
        [
          {
            "node": "Respond 400",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "IF check?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF check?": {
      "main": [
        [
          {
            "node": "Redis GET (check)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Redis GET (record)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Redis GET (check)": {
      "main": [
        [
          {
            "node": "Parse CHECK",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fail-Closed CHECK",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse CHECK": {
      "main": [
        [
          {
            "node": "Respond CHECK",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fail-Closed CHECK": {
      "main": [
        [
          {
            "node": "Respond CHECK",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Redis GET (record)": {
      "main": [
        [
          {
            "node": "Compute New Total",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fail-Closed RECORD",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute New Total": {
      "main": [
        [
          {
            "node": "Redis SET (record)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Redis SET (record)": {
      "main": [
        [
          {
            "node": "Build RECORD success",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fail-Closed RECORD",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build RECORD success": {
      "main": [
        [
          {
            "node": "Respond RECORD",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fail-Closed RECORD": {
      "main": [
        [
          {
            "node": "Respond RECORD",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "UTC"
  }
}