{
  "name": "Factures fournisseurs — Gmail, Mistral OCR, Pennylane, Qonto, Slack",
  "nodes": [
    {
      "parameters": {
        "content": "## Factures fournisseurs — lues, contrôlées, saisies, justifiées\n**1. Arrivée** — chaque pièce jointe PDF reçue sur factures@ devient un élément.\n\n**2. Lecture** — Mistral OCR lit le document, Mistral Small en tire les champs (fournisseur, SIRET, numéro, dates, HT, TVA, TTC).\n\n**3. Contrôle** — TVA recalculée, doublon cherché dans Pennylane. Une anomalie part dans #compta avec la pièce.\n\n**4. Saisie et justificatif** — la facture est importée dans Pennylane ; le prélèvement correspondant est cherché dans Qonto et la facture y est jointe comme justificatif.\n\n**5. Validation** — au-delà de `SEUIL_VALIDATION` (1 000 €), Slack demande un accord d'un clic avant le paiement.\n\nVariables : `MISTRAL_API_KEY`, `PENNYLANE_TOKEN`, `QONTO_LOGIN`, `QONTO_SECRET`, `QONTO_IBAN`, `SEUIL_VALIDATION`.",
        "height": 470,
        "width": 560
      },
      "id": "78ac6010-c21f-50e1-8093-969b5438025f",
      "name": "Lisez-moi",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-460, -560]
    },
    {
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        },
        "simple": false,
        "filters": {
          "q": "has:attachment filename:pdf label:factures"
        },
        "options": {
          "downloadAttachments": true
        }
      },
      "id": "6290a36e-f87d-52c8-8284-776f388c13a1",
      "name": "Gmail · factures@",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.2,
      "position": [-400, 0],
      "credentials": {
        "gmailOAuth2": {
          "id": "",
          "name": "Boîte factures"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Un e-mail peut porter plusieurs factures : une pièce jointe PDF = un élément.\nreturn $input.all().flatMap((mail) =>\n  Object.entries(mail.binary ?? {})\n    .filter(([, f]) => f.mimeType === 'application/pdf')\n    .map(([, f]) => ({\n      json: { expediteur: mail.json.from?.value?.[0]?.address, fichier: f.fileName },\n      binary: { facture: f },\n    })),\n);"
      },
      "id": "f416c510-265c-51a4-b725-5840dcfd9433",
      "name": "Une pièce par facture",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [-160, 0]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.mistral.ai/v1/ocr",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $env.MISTRAL_API_KEY }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'mistral-ocr-latest', document: { type: 'document_url', document_url: 'data:application/pdf;base64,' + $binary.facture.data } }) }}",
        "options": {}
      },
      "id": "e51ce30d-d3ec-54b4-a14a-c45f60fd4641",
      "name": "Mistral · OCR",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [80, 0]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.mistral.ai/v1/chat/completions",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $env.MISTRAL_API_KEY }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'mistral-small-latest', temperature: 0, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: 'Tu lis une facture fournisseur française. Renvoie un objet JSON : fournisseur, siret, numero, date (AAAA-MM-JJ), echeance (AAAA-MM-JJ), ht, tva, ttc (nombres), taux_tva (nombre), iban_fournisseur. Champ illisible : null.' }, { role: 'user', content: $json.pages.map(p => p.markdown).join('\\n\\n') } ] }) }}",
        "options": {}
      },
      "id": "f87a83d6-1328-5ae0-8560-091d4dd1735a",
      "name": "Mistral · extraire les champs",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [320, 0]
    },
    {
      "parameters": {
        "jsCode": "const f = JSON.parse($input.first().json.choices[0].message.content);\nconst tvaAttendue = Math.round(f.ht * (f.taux_tva / 100) * 100) / 100;\nconst anomalies = [];\nif (Math.abs(tvaAttendue - f.tva) > 0.02) anomalies.push(`TVA ${f.tva} € au lieu de ${tvaAttendue} €`);\nif (Math.abs(f.ht + f.tva - f.ttc) > 0.02) anomalies.push('HT + TVA ≠ TTC');\nif (!f.numero) anomalies.push('numéro de facture illisible');\n\n// Le doublon : même fournisseur, même numéro, déjà dans Pennylane.\nconst r = await this.helpers.httpRequest({\n  url: 'https://app.pennylane.com/api/external/v2/supplier_invoices',\n  qs: { filter: JSON.stringify([{ field: 'invoice_number', operator: 'eq', value: f.numero }]) },\n  headers: { Authorization: `Bearer ${$env.PENNYLANE_TOKEN}` },\n  json: true,\n});\nif ((r.items ?? []).length) anomalies.push('déjà saisie dans Pennylane');\n\nreturn [{ json: { ...f, anomalies, conforme: anomalies.length === 0 }, binary: $('Une pièce par facture').item.binary }];"
      },
      "id": "e6f42370-c96f-5dbd-969e-c129d458cda3",
      "name": "Contrôler TVA et doublon",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [560, 0]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "conforme",
              "leftValue": "={{ $json.conforme }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "rightValue": ""
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "211ab860-2fc4-50ed-804d-f9b92bb4f419",
      "name": "Conforme ?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [800, 0]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://app.pennylane.com/api/external/v2/supplier_invoices/import",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $env.PENNYLANE_TOKEN }}"
            }
          ]
        },
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formBinaryData",
              "name": "file",
              "inputDataFieldName": "facture"
            },
            {
              "name": "invoice_number",
              "value": "={{ $json.numero }}"
            },
            {
              "name": "date",
              "value": "={{ $json.date }}"
            },
            {
              "name": "deadline",
              "value": "={{ $json.echeance }}"
            },
            {
              "name": "amount",
              "value": "={{ $json.ttc }}"
            }
          ]
        },
        "options": {}
      },
      "id": "369dd1f5-8d22-5561-acdd-fd1b7de7b999",
      "name": "Pennylane · importer la facture",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1040, -120]
    },
    {
      "parameters": {
        "jsCode": "// Le débit du même montant, dans les 45 jours autour de la facture.\nconst f = $('Contrôler TVA et doublon').item.json;\nconst r = await this.helpers.httpRequest({\n  url: 'https://thirdparty.qonto.com/v2/transactions',\n  qs: { iban: $env.QONTO_IBAN, side: 'debit', 'status[]': 'completed', per_page: 100 },\n  headers: { Authorization: `${$env.QONTO_LOGIN}:${$env.QONTO_SECRET}` },\n  json: true,\n});\nconst date = new Date(f.date).getTime();\nconst t = (r.transactions ?? []).find(\n  (x) => Math.abs(x.amount - f.ttc) < 0.01 && Math.abs(new Date(x.settled_at).getTime() - date) < 45 * 864e5,\n);\nreturn [{ json: { ...f, transaction: t?.id ?? null }, binary: $('Contrôler TVA et doublon').item.binary }];"
      },
      "id": "5866c8fa-7f28-5d40-8adf-2374f4028858",
      "name": "Qonto · trouver le prélèvement",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1280, -120]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://thirdparty.qonto.com/v2/transactions/{{ $json.transaction }}/attachments",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ $env.QONTO_LOGIN }}:{{ $env.QONTO_SECRET }}"
            },
            {
              "name": "X-Qonto-Idempotency-Key",
              "value": "={{ $json.fournisseur }}-{{ $json.numero }}"
            }
          ]
        },
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formBinaryData",
              "name": "file",
              "inputDataFieldName": "facture"
            }
          ]
        },
        "options": {}
      },
      "id": "1dc14bb5-eb40-5a4f-85ff-5e4d1e303581",
      "name": "Qonto · joindre le justificatif",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1520, -200]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "seuil",
              "leftValue": "={{ $json.ttc }}",
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "rightValue": "={{ Number($env.SEUIL_VALIDATION ?? 1000) }}"
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "fa7e3019-7a5d-53a3-8a2c-875134c4bc58",
      "name": "Au-delà du seuil ?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [1520, -40]
    },
    {
      "parameters": {
        "operation": "sendAndWait",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#compta"
        },
        "message": "={{ `Facture ${$json.fournisseur} n° ${$json.numero} : ${$json.ttc.toLocaleString('fr-FR')} € TTC, échéance ${$json.echeance}. Saisie dans Pennylane${$json.transaction ? ', justificatif joint dans Qonto' : ''}.` }}",
        "approvalOptions": {
          "values": {
            "approvalType": "double",
            "approveLabel": "Valider",
            "disapproveLabel": "Bloquer"
          }
        },
        "options": {
          "limitWaitTime": {
            "values": {
              "limitType": "afterTimeInterval",
              "resumeAmount": 2,
              "resumeUnit": "days"
            }
          }
        }
      },
      "id": "4a86a960-bac8-587f-bc82-aac8550ed48a",
      "name": "Slack · demander la validation",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [1760, -40],
      "credentials": {
        "slackApi": {
          "id": "",
          "name": "Slack compta"
        }
      },
      "webhookId": "f026e321-30f4-53e2-b514-5687ce47a3c6"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#compta"
        },
        "text": "={{ `⚠️ Facture ${$json.fournisseur ?? '?'} n° ${$json.numero ?? '?'} mise de côté : ${$json.anomalies.join(', ')}.` }}",
        "otherOptions": {}
      },
      "id": "26f4f5ef-0a11-5518-8c27-01ccfcf7d090",
      "name": "Slack · signaler l'anomalie",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [1040, 120],
      "credentials": {
        "slackApi": {
          "id": "",
          "name": "Slack compta"
        }
      }
    }
  ],
  "connections": {
    "Gmail · factures@": {
      "main": [
        [
          {
            "node": "Une pièce par facture",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Une pièce par facture": {
      "main": [
        [
          {
            "node": "Mistral · OCR",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mistral · OCR": {
      "main": [
        [
          {
            "node": "Mistral · extraire les champs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mistral · extraire les champs": {
      "main": [
        [
          {
            "node": "Contrôler TVA et doublon",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contrôler TVA et doublon": {
      "main": [
        [
          {
            "node": "Conforme ?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Conforme ?": {
      "main": [
        [
          {
            "node": "Pennylane · importer la facture",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack · signaler l'anomalie",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pennylane · importer la facture": {
      "main": [
        [
          {
            "node": "Qonto · trouver le prélèvement",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Qonto · trouver le prélèvement": {
      "main": [
        [
          {
            "node": "Qonto · joindre le justificatif",
            "type": "main",
            "index": 0
          },
          {
            "node": "Au-delà du seuil ?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Au-delà du seuil ?": {
      "main": [
        [
          {
            "node": "Slack · demander la validation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Europe/Paris"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  }
}
