{
  "name": "Sellf: Zakup \u2192 Faktura KSeF",
  "nodes": [
    {
      "parameters": {
        "content": "## Tw\u00f3rz faktur\u0119 KSeF przy ka\u017cdym zakupie w Sellf\n\n### Jak to dzia\u0142a\nTen workflow reaguje na [Sellf](https://github.com/jurczykpawel/sellf) - self-hostowan\u0105 platform\u0119 checkoutu do produkt\u00f3w cyfrowych - kt\u00f3ra wysy\u0142a podpisany webhook `purchase.completed` przy ka\u017cdym op\u0142aconym zam\u00f3wieniu. Weryfikuje nag\u0142\u00f3wek `X-Sellf-Signature`, sprawdza, czy klient poprosi\u0142 o faktur\u0119 i poda\u0142 NIP, buduje gotowy payload KSeF i wysy\u0142a go do Twojego self-hostowanego [KSeF Gateway](https://github.com/jurczykpawel/ksef-gateway) - niewielkiej nak\u0142adki HTTP na oficjalne API polskiego e-fakturowania. Przy sukcesie zwr\u00f3cony `ksefNumber` jest zapisywany; przy b\u0142\u0119dzie zapisywana jest tre\u015b\u0107 b\u0142\u0119du, \u017ceby\u015b m\u00f3g\u0142 na ni\u0105 zareagowa\u0107.\n\n### Konfiguracja\n1. Wdr\u00f3\u017c instancj\u0119 KSeF Gateway (Docker, Render albo StackPilot - patrz README projektu) i zanotuj jej adres URL. Nowy w KSeF albo potrzebujesz certyfikatu (tokeny KSeF b\u0119d\u0105 wycofywane)? Zobacz [g\u0142\u00f3wny README](https://github.com/jurczykpawel/ksef-gateway#certificate-based-auth-alternative-to-tokens).\n2. Otw\u00f3rz **Configuration (EDIT ME)** i uzupe\u0142nij URL bramki, klucz API bramki, dane sprzedawcy oraz sekret podpisuj\u0105cy webhooki z Sellf \u2192 Ustawienia \u2192 Webhooks.\n3. Wklej Production URL node'a **Sellf Webhook** w tym samym miejscu w ustawieniach Sellf.\n4. Zobacz czerwon\u0105 notatk\u0119 przy **Verify Signature** - jedno wymagane ustawienie \u015brodowiska n8n.\n\n### Wskaz\u00f3wki dotycz\u0105ce personalizacji\nPod\u0142\u0105cz **Log Error** do Slacka, maila albo bazy danych, \u017ceby dostawa\u0107 alert o nieudanych zg\u0142oszeniach do KSeF.",
        "height": 720,
        "width": 700,
        "color": 1
      },
      "id": "sticky-overview",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -760,
        -220
      ]
    },
    {
      "parameters": {
        "content": "## Odbi\u00f3r i weryfikacja\nPrzyjmuje webhook Sellf i sprawdza jego podpis HMAC, zanim zaufa danym.",
        "height": 510,
        "width": 1120,
        "color": 7
      },
      "id": "sticky-section-a",
      "name": "Section: Receive & Verify",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -760,
        520
      ]
    },
    {
      "parameters": {
        "content": "## Potwierdzenie i wyodr\u0119bnienie\nOdpowiada natychmiast, a potem filtruje op\u0142acone zakupy, w kt\u00f3rych poproszono o faktur\u0119 z NIP-em.",
        "height": 350,
        "width": 820,
        "color": 7
      },
      "id": "sticky-section-b",
      "name": "Section: Acknowledge & Extract",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        440,
        520
      ]
    },
    {
      "parameters": {
        "content": "## Budowa faktury\nSk\u0142ada gotowy payload faktury KSeF z danych sprzedawcy i zam\u00f3wienia.",
        "height": 350,
        "width": 520,
        "color": 7
      },
      "id": "sticky-section-c",
      "name": "Section: Build the Invoice",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1340,
        520
      ]
    },
    {
      "parameters": {
        "content": "## Wysy\u0142ka i zapis wyniku\nWysy\u0142a faktur\u0119 do Twojego KSeF Gateway i zapisuje zwr\u00f3cony numer KSeF albo b\u0142\u0105d, je\u015bli si\u0119 nie uda.",
        "height": 670,
        "width": 820,
        "color": 7
      },
      "id": "sticky-section-d",
      "name": "Section: Send & Log Result",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1940,
        360
      ]
    },
    {
      "parameters": {
        "content": "## Wymagane ustawienie node'a\nTen node Code potrzebuje `require('crypto')`. Self-hostowany n8n: ustaw `NODE_FUNCTION_ALLOW_BUILTIN=crypto` w \u015brodowisku n8n i zrestartuj.",
        "height": 220,
        "width": 300,
        "color": 3
      },
      "id": "sticky-warning",
      "name": "Warning: crypto module",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -130,
        870
      ]
    },
    {
      "parameters": {
        "path": "sellf-purchase-ksef",
        "httpMethod": "POST",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "id": "wh-1",
      "name": "Sellf Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -700,
        710
      ],
      "notes": "Configure this webhook URL in Sellf admin panel \u2192 Settings \u2192 Webhooks. Raw Body is required so the signature check below sees the exact bytes Sellf signed.",
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "a1",
              "name": "ksefGatewayUrl",
              "value": "https://your-ksef-gateway.example.com",
              "type": "string"
            },
            {
              "id": "a2",
              "name": "sellerNip",
              "value": "0000000000",
              "type": "string"
            },
            {
              "id": "a3",
              "name": "sellerName",
              "value": "Your Company sp. z o.o.",
              "type": "string"
            },
            {
              "id": "a4",
              "name": "sellerStreet",
              "value": "ul. Example 1",
              "type": "string"
            },
            {
              "id": "a5",
              "name": "sellerCity",
              "value": "00-001 Warszawa",
              "type": "string"
            },
            {
              "id": "a6",
              "name": "defaultVatRate",
              "value": 23,
              "type": "number"
            },
            {
              "id": "a7",
              "name": "webhookSecret",
              "value": "change-me-webhook-secret",
              "type": "string"
            },
            {
              "id": "a8",
              "name": "gatewayApiKey",
              "value": "your-gateway-api-key",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {
          "stripBinary": false
        }
      },
      "id": "config-1",
      "name": "Configuration (EDIT ME)",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -400,
        710
      ],
      "notes": "Fill in your KSeF Gateway URL, seller details, and webhook signing secret before activating this workflow."
    },
    {
      "parameters": {
        "jsCode": "const crypto = require('crypto');\nconst config = $('Configuration (EDIT ME)').first().json;\nconst item = $input.first();\n\nconst header = (item.json.headers && item.json.headers['x-sellf-signature']) || '';\nconst rawBody = item.binary && item.binary.data\n  ? Buffer.from(item.binary.data.data, 'base64').toString('utf8')\n  : JSON.stringify(item.json.body || {});\n\nlet t = null;\nlet v1 = null;\nfor (const part of header.split(',')) {\n  const eq = part.indexOf('=');\n  if (eq === -1) continue;\n  const key = part.slice(0, eq).trim();\n  const value = part.slice(eq + 1).trim();\n  if (key === 't') t = value;\n  else if (key === 'v1') v1 = value;\n}\n\nlet valid = false;\nif (t && v1 && config.webhookSecret) {\n  const expected = crypto.createHmac('sha256', config.webhookSecret).update(`${t}.${rawBody}`).digest('hex');\n  const withinTolerance = Math.abs(Math.floor(Date.now() / 1000) - Number(t)) <= 300;\n  const sameLength = expected.length === v1.length;\n  valid = withinTolerance && sameLength && crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'));\n}\n\nreturn [{ json: { valid, body: JSON.parse(rawBody) } }];"
      },
      "id": "code-verify",
      "name": "Verify Signature",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -100,
        710
      ],
      "notes": "Verifies Sellf's t=...,v1=... HMAC-SHA256 header (see Sellf docs: webhook-queue/signature.ts) and re-parses the raw body for downstream nodes."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "leftValue": "={{ $json.valid }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true"
              }
            }
          ]
        },
        "options": {}
      },
      "id": "if-sig",
      "name": "Signature Valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        200,
        710
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "{\"success\":false,\"error\":\"invalid signature\"}",
        "options": {
          "responseCode": 401
        }
      },
      "id": "res-unauth",
      "name": "Respond Unauthorized",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        200,
        870
      ],
      "notes": "Dead end: bad or missing signature. Nothing downstream trusts this payload."
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "{\"status\":\"ok\"}"
      },
      "id": "res-1",
      "name": "Respond OK",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        500,
        710
      ],
      "notes": "Responds immediately; the rest of the workflow keeps running afterwards to build and send the invoice."
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "leftValue": "={{ $json.body.event }}",
                    "rightValue": "purchase.completed",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "purchase.completed"
            }
          ]
        },
        "options": {
          "fallbackOutput": "none"
        }
      },
      "id": "sw-1",
      "name": "Is Purchase?",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        800,
        710
      ]
    },
    {
      "parameters": {
        "jsCode": "const data = $input.first().json.body.data;\n\nreturn [{\n  json: {\n    email: data.customer.email,\n    firstName: data.customer.firstName || '',\n    productName: data.product.name,\n    amount: data.order.amount / 100,\n    currency: data.order.currency.toUpperCase(),\n    sessionId: data.order.sessionId,\n    paymentIntentId: data.order.paymentIntentId,\n    needsInvoice: data.invoice?.needsInvoice || false,\n    invoiceNip: data.invoice?.nip || null,\n    invoiceCompany: data.invoice?.companyName || null,\n    invoiceAddress: data.invoice?.address || null,\n    invoiceCity: data.invoice?.city || null,\n    invoicePostalCode: data.invoice?.postalCode || null,\n    invoiceCountry: data.invoice?.country || 'PL'\n  }\n}];"
      },
      "id": "code-1",
      "name": "Extract Purchase Data",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        710
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "leftValue": "={{ $json.needsInvoice }}",
                    "rightValue": true,
                    "operator": {
                      "type": "boolean",
                      "operation": "true"
                    }
                  },
                  {
                    "leftValue": "={{ $json.invoiceNip }}",
                    "rightValue": "",
                    "operator": {
                      "type": "string",
                      "operation": "notEquals"
                    }
                  }
                ]
              },
              "renameOutput": true,
              "outputKey": "Needs Invoice"
            }
          ]
        },
        "options": {
          "fallbackOutput": "none"
        }
      },
      "id": "sw-2",
      "name": "Needs Invoice?",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1400,
        710
      ],
      "notes": "Proceeds only when customer requested an invoice AND provided NIP. Both conditions required."
    },
    {
      "parameters": {
        "jsCode": "const d = $input.first().json;\nconst config = $('Configuration (EDIT ME)').first().json;\nconst today = new Date().toISOString().split('T')[0];\n\n// Build invoice number from session ID suffix - adjust this scheme if you have your own\nconst suffix = (d.sessionId || 'XXXX').slice(-4).toUpperCase();\nconst invoiceNumber = `FV/${today.replace(/-/g, '/')}/${suffix}`;\n\nconst buyerCity = d.invoicePostalCode\n  ? `${d.invoicePostalCode} ${d.invoiceCity}`\n  : d.invoiceCity || '-';\n\nconst seller = {\n  nip: config.sellerNip,\n  name: config.sellerName,\n  address: {\n    street: config.sellerStreet,\n    city: config.sellerCity\n  }\n};\n\nreturn [{\n  json: {\n    invoiceNumber,\n    issueDate: today,\n    saleDate: today,\n    issuePlace: seller.address.city.split(' ').slice(1).join(' ') || 'Warszawa',\n    currency: d.currency || 'PLN',\n    type: 'VAT',\n    seller,\n    buyer: {\n      nip: d.invoiceNip,\n      name: d.invoiceCompany || d.email,\n      address: {\n        street: d.invoiceAddress || '-',\n        city: buyerCity,\n        country: d.invoiceCountry || 'PL'\n      }\n    },\n    items: [\n      {\n        name: d.productName,\n        unit: 'szt.',\n        quantity: 1,\n        unitPrice: d.amount,\n        vatRate: config.defaultVatRate\n      }\n    ],\n    payment: {\n      paid: true,\n      date: today,\n      method: 'transfer'\n    },\n    // Pass-through for error logging\n    _meta: {\n      sessionId: d.sessionId,\n      paymentIntentId: d.paymentIntentId,\n      buyerEmail: d.email\n    }\n  }\n}];"
      },
      "id": "code-2",
      "name": "Build KSeF Payload",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1700,
        710
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Configuration (EDIT ME)').item.json.ksefGatewayUrl }}/ksef/invoice",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "X-Api-Key",
              "value": "={{ $('Configuration (EDIT ME)').item.json.gatewayApiKey }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ invoiceNumber: $json.invoiceNumber, issueDate: $json.issueDate, saleDate: $json.saleDate, issuePlace: $json.issuePlace, currency: $json.currency, type: $json.type, seller: $json.seller, buyer: $json.buyer, items: $json.items, payment: $json.payment }) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "http-1",
      "name": "Send to KSeF Gateway",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2000,
        710
      ],
      "notes": "The gateway authenticates with KSeF internally - no auth headers needed here."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "leftValue": "={{ $json.success }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true"
              }
            }
          ]
        },
        "options": {}
      },
      "id": "if-1",
      "name": "Success?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2300,
        710
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "ksefNumber",
              "name": "ksefNumber",
              "value": "={{ $json.data.ksefNumber }}",
              "type": "string"
            },
            {
              "id": "status",
              "name": "status",
              "value": "={{ $json.data.status }}",
              "type": "string"
            },
            {
              "id": "sessionId",
              "name": "sessionId",
              "value": "={{ $('Build KSeF Payload').item.json._meta.sessionId }}",
              "type": "string"
            },
            {
              "id": "buyerEmail",
              "name": "buyerEmail",
              "value": "={{ $('Build KSeF Payload').item.json._meta.buyerEmail }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "set-1",
      "name": "Extract KSeF Result",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2600,
        550
      ],
      "notes": "ksefNumber - save this to your DB (Supabase payment_transactions) for audit trail.\nOptionally chain a Supabase node here to persist it."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "error",
              "name": "error",
              "value": "={{ $json.error || JSON.stringify($json) }}",
              "type": "string"
            },
            {
              "id": "sessionId",
              "name": "sessionId",
              "value": "={{ $('Build KSeF Payload').item.json._meta.sessionId }}",
              "type": "string"
            },
            {
              "id": "invoiceNumber",
              "name": "invoiceNumber",
              "value": "={{ $('Build KSeF Payload').item.json.invoiceNumber }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "set-2",
      "name": "Log Error",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2600,
        870
      ],
      "notes": "TODO: Connect to Slack / email / Supabase audit_log to alert on failed KSeF submissions."
    }
  ],
  "connections": {
    "Sellf Webhook": {
      "main": [
        [
          {
            "node": "Configuration (EDIT ME)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configuration (EDIT ME)": {
      "main": [
        [
          {
            "node": "Verify Signature",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify Signature": {
      "main": [
        [
          {
            "node": "Signature Valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature Valid?": {
      "main": [
        [
          {
            "node": "Respond OK",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond Unauthorized",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond OK": {
      "main": [
        [
          {
            "node": "Is Purchase?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Purchase?": {
      "main": [
        [
          {
            "node": "Extract Purchase Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Purchase Data": {
      "main": [
        [
          {
            "node": "Needs Invoice?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Needs Invoice?": {
      "main": [
        [
          {
            "node": "Build KSeF Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build KSeF Payload": {
      "main": [
        [
          {
            "node": "Send to KSeF Gateway",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send to KSeF Gateway": {
      "main": [
        [
          {
            "node": "Success?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Success?": {
      "main": [
        [
          {
            "node": "Extract KSeF Result",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": true
  }
}