{ "name": "Pennylane Invoice Creator", "nodes": [ { "parameters": { "httpMethod": "POST", "path": "pennylane-invoice-creator", "responseMode": "responseNode", "options": {}, "authentication": "basicAuth" }, "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ -780, 0 ], "id": "402c4d14-ab30-4baf-85b5-daec4ceb06f4", "name": "Receive Invoice Request" }, { "id": "validate-pennylane", "name": "Validate Invoice Request", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -540, 0 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "/*\nMIT License\nCopyright (c) 2026 Gauthier Huguenin\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n*/\n\nfunction validatePennylaneInvoice(input, now = Date.now()) {\n const reject = (error, details, statusCode = 400) => ({ accepted: false, data: {}, filter: '', customer: {}, body: { success: false, error, details }, statusCode });\n if (!input || typeof input !== 'object' || Array.isArray(input)) return reject('invalid_payload', ['A JSON object is required.']);\n if (input.send_email !== undefined && input.send_email !== false) return reject('email_not_supported', ['Omit send_email or set it to false. This workflow creates invoices without sending email.'], 422);\n const errors = [];\n if (typeof input.customer_name !== 'string' || !input.customer_name.trim()) errors.push('customer_name is required');\n if (typeof input.customer_email !== 'string' || !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(input.customer_email)) errors.push('customer_email must be a valid email address');\n if (!Array.isArray(input.items) || !input.items.length) errors.push('items must be a nonempty array');\n else input.items.forEach((item, index) => {\n if (!item || typeof item !== 'object' || Array.isArray(item)) { errors.push('items[' + index + '] must be an object'); return; }\n if (typeof item.label !== 'string' || !item.label.trim()) errors.push('items[' + index + '].label is required');\n if (typeof item.quantity !== 'number' || !Number.isFinite(item.quantity) || item.quantity <= 0) errors.push('items[' + index + '].quantity must be a number > 0');\n if (typeof item.unit_price !== 'number' || !Number.isFinite(item.unit_price) || item.unit_price <= 0 || !/^\\d+\\.\\d{2}$/.test(item.unit_price.toFixed(2))) errors.push('items[' + index + '].unit_price must be a positive finite number representable with two decimal places');\n if (item.vat_rate !== undefined && (typeof item.vat_rate !== 'string' || !item.vat_rate.trim())) errors.push('items[' + index + '].vat_rate must be a nonempty Pennylane VAT code');\n });\n if (input.customer_country !== undefined && (typeof input.customer_country !== 'string' || !/^[A-Z]{2}$/.test(input.customer_country))) errors.push('customer_country must be an uppercase two-letter country code');\n if (input.currency !== undefined && (typeof input.currency !== 'string' || !/^[A-Z]{3}$/.test(input.currency))) errors.push('currency must be an uppercase three-letter currency code');\n if (input.reference !== undefined && input.reference !== null && (typeof input.reference !== 'string' || !input.reference.trim())) errors.push('reference must be nonempty text when supplied');\n if (input.due_date !== undefined && (typeof input.due_date !== 'string' || !/^\\d{4}-\\d{2}-\\d{2}$/.test(input.due_date) || !Number.isFinite(Date.parse(input.due_date + 'T00:00:00Z')) || new Date(input.due_date + 'T00:00:00Z').toISOString().slice(0, 10) !== input.due_date)) errors.push('due_date must be a real date in YYYY-MM-DD format');\n if (errors.length) return reject('invalid_payload', errors);\n const data = {\n customer_name: input.customer_name.trim(), customer_email: input.customer_email.toLowerCase(),\n customer_address: typeof input.customer_address === 'string' ? input.customer_address : '',\n customer_postal_code: typeof input.customer_postal_code === 'string' ? input.customer_postal_code : '00000',\n customer_city: typeof input.customer_city === 'string' ? input.customer_city : 'Non renseigné',\n customer_country: input.customer_country ?? 'FR', currency: input.currency ?? 'EUR', reference: input.reference ?? null,\n items: input.items.map(item => ({ label: item.label.trim(), quantity: item.quantity, unit_price: item.unit_price, vat_rate: item.vat_rate ?? 'FR_200' })),\n due_date: input.due_date ?? new Date(now + 30 * 86400000).toISOString().slice(0, 10), invoice_date: new Date(now).toISOString().slice(0, 10), send_email: false,\n };\n return { accepted: true, data, filter: JSON.stringify([{ field: 'emails', operator: 'in', value: [data.customer_email] }]), customer: { name: data.customer_name, emails: [data.customer_email], billing_address: { address: data.customer_address, postal_code: data.customer_postal_code, city: data.customer_city, country_alpha2: data.customer_country } }, body: {}, statusCode: 200 };\n}\n\nfunction authorizePennylaneInvoice({ body, headers, expectedAuthorization }) {\n const credentials = header => {\n if (typeof header !== 'string') return '';\n const match = /^ *Basic +([A-Za-z0-9+/]+={0,2}) *$/i.exec(header);\n if (!match) return '';\n const decoded = Buffer.from(match[1], 'base64').toString('utf8');\n const separator = decoded.indexOf(':');\n return separator > 0 && separator < decoded.length - 1 && !/[\\r\\n]/.test(decoded) ? decoded : '';\n };\n const expected = credentials(expectedAuthorization);\n if (!expected) throw new Error('Configure the protected webhook Basic authorization secret with a nonempty username and password before enabling this workflow.');\n const values = Object.entries(headers ?? {}).filter(([name]) => name.toLowerCase() === 'authorization');\n const received = values.length === 1 ? credentials(values[0][1]) : '';\n // Compare decoded username/password without returning either credential.\n let difference = received.length ^ expected.length;\n for (let index = 0; index < Math.max(received.length, expected.length); index++) difference |= (received.charCodeAt(index) || 0) ^ (expected.charCodeAt(index) || 0);\n if (difference !== 0) return { accepted: false, data: {}, filter: '', customer: {}, body: { success: false, error: 'unauthorized' }, statusCode: 401 };\n return validatePennylaneInvoice(body);\n}\n\nreturn [{json:validatePennylaneInvoice($input.first().json.body)}];\n" } }, { "id": "accepted-pennylane", "name": "Invoice Request Accepted?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ -300, 0 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "accepted-pennylane-case", "leftValue": "={{ $json.accepted }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "reject-pennylane", "name": "Return Rejected Request", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -40, 260 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "function pennylaneInvoiceReply({ body, statusCode, data, rejected }) {\n const headers = { 'Cache-Control': 'no-store', ...(statusCode === 401 ? { 'WWW-Authenticate': 'Basic realm=\"Webhook\"' } : {}) };\n if (rejected) return { body, statusCode, headers };\n if (statusCode !== 201 || !body || typeof body !== 'object' || !Number.isSafeInteger(body.id) || body.id <= 0 || typeof body.invoice_number !== 'string' || !body.invoice_number.trim() || typeof body.currency_amount !== 'string' || !body.currency_amount.trim() || (body.public_file_url !== null && typeof body.public_file_url !== 'string')) throw new Error('Pennylane did not confirm a completed invoice with its documented HTTP 201 fields.');\n return { body: { success: true, invoice_id: String(body.id), invoice_number: body.invoice_number, amount: body.currency_amount, customer: data.customer_name, public_url: body.public_file_url || null, email_sent: false }, statusCode: 200, headers };\n}\n\nreturn [{json:pennylaneInvoiceReply({body:$json.body,statusCode:$json.statusCode,data:{},rejected:true})}];\n" } }, { "parameters": { "method": "GET", "url": "https://app.pennylane.com/api/external/v2/customers", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "filter", "value": "={{ $json.filter }}" } ] }, "options": { "timeout": 15000, "redirect": { "redirect": { "followRedirects": false } }, "response": { "response": { "fullResponse": true, "responseFormat": "json" } } } }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ -40, -100 ], "id": "5440a265-819f-42d4-8c26-d83cb51eb219", "name": "Find Customer by Email" }, { "id": "select-pennylane", "name": "Select First Matching Customer", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 200, -100 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "function selectPennylaneCustomer({ body, statusCode, data }) {\n if (statusCode !== 200 || !body || typeof body !== 'object' || !Array.isArray(body.items)) throw new Error('Pennylane customer lookup did not return its documented list response.');\n if (!body.items.length) return { found: false, customerId: 0 };\n const id = body.items[0]?.id;\n if (!Number.isSafeInteger(id) || id <= 0) throw new Error('The first matching Pennylane customer has no valid integer ID.');\n const emails = body.items[0].emails;\n if (!Array.isArray(emails) || !emails.some(email => typeof email === 'string' && email.trim().toLowerCase() === data.customer_email)) throw new Error('The first returned Pennylane customer does not contain the requested email. No invoice was created.');\n return { found: true, customerId: id };\n}\n\nreturn [{json:selectPennylaneCustomer({...$json,data:$('Validate Invoice Request').first().json.data})}];\n" } }, { "id": "new-pennylane", "name": "Create a New Customer?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 440, -100 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "new-pennylane-case", "leftValue": "={{ !$json.found }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "parameters": { "method": "POST", "url": "https://app.pennylane.com/api/external/v2/company_customers", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ $('Validate Invoice Request').first().json.customer }}", "options": { "timeout": 15000, "redirect": { "redirect": { "followRedirects": false } }, "response": { "response": { "fullResponse": true, "responseFormat": "json" } } } }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 680, -220 ], "id": "106b531c-143f-4de7-ba98-8ede691a0d01", "name": "Create Company Customer" }, { "id": "created-pennylane", "name": "Build Invoice for New Customer", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 920, -220 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "function buildPennylaneInvoice({ data, mode, body, statusCode, customerId }) {\n let id = customerId;\n if (mode === 'created') {\n if (statusCode !== 201 || !body || typeof body !== 'object') throw new Error('Pennylane did not confirm customer creation with HTTP 201.');\n id = body.id;\n } else if (mode !== 'existing') throw new Error('Unknown customer selection path.');\n if (!Number.isSafeInteger(id) || id <= 0) throw new Error('A valid Pennylane customer ID is required before creating an invoice.');\n const payload = {\n customer_id: id, date: data.invoice_date, deadline: data.due_date, currency: data.currency,\n invoice_lines: data.items.map(item => ({ label: item.label, quantity: item.quantity, unit: 'piece', raw_currency_unit_price: String(item.unit_price.toFixed(2)), vat_rate: item.vat_rate })), draft: false,\n };\n if (data.reference) payload.external_reference = data.reference;\n return { payload };\n}\n\nreturn [{json:buildPennylaneInvoice({data:$('Validate Invoice Request').first().json.data,mode:'created',body:$json.body,statusCode:$json.statusCode,customerId:0})}];\n" } }, { "id": "existing-pennylane", "name": "Build Invoice for Existing Customer", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 680, 40 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "function buildPennylaneInvoice({ data, mode, body, statusCode, customerId }) {\n let id = customerId;\n if (mode === 'created') {\n if (statusCode !== 201 || !body || typeof body !== 'object') throw new Error('Pennylane did not confirm customer creation with HTTP 201.');\n id = body.id;\n } else if (mode !== 'existing') throw new Error('Unknown customer selection path.');\n if (!Number.isSafeInteger(id) || id <= 0) throw new Error('A valid Pennylane customer ID is required before creating an invoice.');\n const payload = {\n customer_id: id, date: data.invoice_date, deadline: data.due_date, currency: data.currency,\n invoice_lines: data.items.map(item => ({ label: item.label, quantity: item.quantity, unit: 'piece', raw_currency_unit_price: String(item.unit_price.toFixed(2)), vat_rate: item.vat_rate })), draft: false,\n };\n if (data.reference) payload.external_reference = data.reference;\n return { payload };\n}\n\nreturn [{json:buildPennylaneInvoice({data:$('Validate Invoice Request').first().json.data,mode:'existing',body:{},statusCode:200,customerId:$json.customerId})}];\n" } }, { "parameters": { "method": "POST", "url": "https://app.pennylane.com/api/external/v2/customer_invoices", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ $json.payload }}", "options": { "timeout": 15000, "redirect": { "redirect": { "followRedirects": false } }, "response": { "response": { "fullResponse": true, "responseFormat": "json" } } } }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 1160, -80 ], "id": "c33bd39f-5043-418c-88f9-0aaa8bc25c9d", "name": "Create Finalized Invoice" }, { "id": "confirm-pennylane", "name": "Confirm Created Invoice", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1400, -80 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "function pennylaneInvoiceReply({ body, statusCode, data, rejected }) {\n const headers = { 'Cache-Control': 'no-store', ...(statusCode === 401 ? { 'WWW-Authenticate': 'Basic realm=\"Webhook\"' } : {}) };\n if (rejected) return { body, statusCode, headers };\n if (statusCode !== 201 || !body || typeof body !== 'object' || !Number.isSafeInteger(body.id) || body.id <= 0 || typeof body.invoice_number !== 'string' || !body.invoice_number.trim() || typeof body.currency_amount !== 'string' || !body.currency_amount.trim() || (body.public_file_url !== null && typeof body.public_file_url !== 'string')) throw new Error('Pennylane did not confirm a completed invoice with its documented HTTP 201 fields.');\n return { body: { success: true, invoice_id: String(body.id), invoice_number: body.invoice_number, amount: body.currency_amount, customer: data.customer_name, public_url: body.public_file_url || null, email_sent: false }, statusCode: 200, headers };\n}\n\nreturn [{json:pennylaneInvoiceReply({body:$json.body,statusCode:$json.statusCode,data:$('Validate Invoice Request').first().json.data,rejected:false})}];\n" } }, { "id": "respond-pennylane", "name": "Invoice Response", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.4, "position": [ 1640, 0 ], "parameters": { "respondWith": "json", "responseBody": "={{ $json.body }}", "options": { "responseCode": "={{ $json.statusCode }}", "responseHeaders": { "entries": [ { "name": "Cache-Control", "value": "no-store" } ] } } } }, { "parameters": { "content": "## MIT License notice\n\nCopyright (c) 2026 Gauthier Huguenin\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.", "height": 720, "width": 720 }, "type": "n8n-nodes-base.stickyNote", "typeVersion": 1, "position": [ -780, 460 ], "id": "47e18308-e272-430e-b4d6-88727f9f3661", "name": "MIT License Notice" } ], "pinData": {}, "connections": { "Receive Invoice Request": { "main": [ [ { "node": "Validate Invoice Request", "type": "main", "index": 0 } ] ] }, "Validate Invoice Request": { "main": [ [ { "node": "Invoice Request Accepted?", "type": "main", "index": 0 } ] ] }, "Invoice Request Accepted?": { "main": [ [ { "node": "Find Customer by Email", "type": "main", "index": 0 } ], [ { "node": "Return Rejected Request", "type": "main", "index": 0 } ] ] }, "Return Rejected Request": { "main": [ [ { "node": "Invoice Response", "type": "main", "index": 0 } ] ] }, "Find Customer by Email": { "main": [ [ { "node": "Select First Matching Customer", "type": "main", "index": 0 } ] ] }, "Select First Matching Customer": { "main": [ [ { "node": "Create a New Customer?", "type": "main", "index": 0 } ] ] }, "Create a New Customer?": { "main": [ [ { "node": "Create Company Customer", "type": "main", "index": 0 } ], [ { "node": "Build Invoice for Existing Customer", "type": "main", "index": 0 } ] ] }, "Create Company Customer": { "main": [ [ { "node": "Build Invoice for New Customer", "type": "main", "index": 0 } ] ] }, "Build Invoice for New Customer": { "main": [ [ { "node": "Create Finalized Invoice", "type": "main", "index": 0 } ] ] }, "Build Invoice for Existing Customer": { "main": [ [ { "node": "Create Finalized Invoice", "type": "main", "index": 0 } ] ] }, "Create Finalized Invoice": { "main": [ [ { "node": "Confirm Created Invoice", "type": "main", "index": 0 } ] ] }, "Confirm Created Invoice": { "main": [ [ { "node": "Invoice Response", "type": "main", "index": 0 } ] ] } }, "active": false, "settings": { "executionOrder": "v1" }, "tags": [] }