{"name":"Re-screen French suppliers weekly with Sirenic KYB, Google Sheets, and Slack","nodes":[{"parameters":{"content":"## Re-screen French suppliers weekly with Sirenic KYB, Google Sheets, and Slack\n\nA weekly change check finds what moved at each supplier; only new, changed or due ones get a full KYB file, under a KYB spending cap.\n\n### Who's it for\n\nProcurement, finance and compliance teams keeping French supplier due diligence current.\n\n### How it works\n\n1. The sheet is read; rows Sirenic would refuse are skipped and named.\n2. `/v1/identite/lot` with `depuis` reads identity and changes, in batches.\n3. Ceased suppliers stop, unresolved ones go to review; new, changed or due ones get a full KYB file, batched.\n4. Readable rules approve, review or stop; Slack flags new stops and reviews; rows and audit log get a dated source line.\n\n### How to set up\n\n1. Create an API key at https://api.sirenic.eu/compte?utm_source=n8n&utm_campaign=w13-supplier-rescreening-sheets-slack.\n2. Add a Header Auth credential (`X-Api-Key`, your `srn_live_` key) on the four Sirenic request nodes.\n3. A Suppliers tab headed Supplier, Identifier, SIREN, Decision, Reasons, Last run, Last checked, Last full check, Sirenic: source, and an Audit log tab; select them on the sheet nodes.\n4. Pick a Slack channel on the Slack nodes, check **Your settings**, then **Run the check now**.\n\n### Requirements\n\n- n8n Cloud or self-hosted.\n- A Sirenic API key. Change check: free within a verified account's free calls (50 lines a call), else $0.003 a line, $0.01 with changes. KYB files: **$0.105 per company** batched, $0.15 alone, under your cap. Errors never billed.\n- Google Sheets and Slack credentials.\n\n### How to customize\n\nEdit Your settings (cadence, KYB spending cap, change types, retry wait) or the Decide rules. The source line can be edited or removed.\n\n_Official sources: INSEE Sirene, INPI RNE, BODACC, Judilibre, VIES and sanctions lists. A sanctions match is a name to verify, never a decision._","height":1300,"width":560},"id":"56dd920d-507a-4c9f-a80c-d37fade1a8ab","name":"Overview","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-670,-810]},{"parameters":{"content":"## 1. Start every Monday, or now\n\n**Your settings**: full check cadence, KYB spending cap per run (credits), full-file change types, batch sizes, retry wait. With etablissement_ouvert, etablissement_ferme, marche_public_attribue, certification_rge_obtenue or certification_rge_echue, the change window stays on their monthly stock: a file may be rebought until the next one.","height":800,"width":460,"color":7},"id":"06054439-c054-442a-afe4-3776db969f5e","name":"Section 1","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-50,-410]},{"parameters":{"content":"## 2. Read and clean the supplier list\n\nOne row per supplier: a SIREN, SIRET, French VAT number or name|postcode. Rows Sirenic would refuse are skipped and named. Fewer than two suppliers: Slack says so and nothing is called.","height":800,"width":720,"color":7},"id":"6a033b7b-862f-4b6c-a38e-4d1cc592c7f0","name":"Section 2","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[470,-210]},{"parameters":{"content":"## 3. What changed since last run\n\nFree up to 50 lines a call in a verified account's free quota, else $0.003 a line, $0.01 with changes, outside the KYB cap. A 429 or 503 is retried once, then left for next run. Errors never billed.","height":1000,"width":2020,"color":7},"id":"9d2afc52-597d-401c-aee2-c0ac06688381","name":"Section 3","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1250,-210]},{"parameters":{"content":"## 4. Who needs a full KYB file\n\nCeased: stop. Unresolved: review. New, changed (default: insolvency, court decisions, cessation, register changes such as officers or head office, sanctions, even undated) or not fully checked for N days: a full file, within your KYB spending cap. Unread source: next run.","height":400,"width":460,"color":7},"id":"6a38ddd9-55a4-4b0d-a146-70a5f457460a","name":"Section 4","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[3330,-210]},{"parameters":{"content":"## 5. Full KYB files, batched\n\nTwo to 100 SIREN per call at $0.105 each, one file at $0.15, within the KYB cap. One retry on 429 or 503. 402: no credits, nothing billed. A refused SIREN (sole trader, restricted diffusion): review, never billed, call resent without it.","height":1400,"width":2020,"color":7},"id":"957416ec-68fa-4348-ac31-d667c4eaa260","name":"Section 5","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[3850,-610]},{"parameters":{"content":"## 6. Decide with rules you can read\n\nStop a ceased company. Send insolvency notices, sanctions matches and any block missing or not fully read to a human. Approve only a complete, clean file. Then compare with the last decision.","height":400,"width":980,"color":7},"id":"4eb6ed10-9eb5-41e3-ae40-05008c2eea6a","name":"Section 6","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[5930,-210]},{"parameters":{"content":"## 7. Write the sheet and the audit log first\n\nEach supplier checked this run gets its row updated and a line in the audit log. Alerts and the summary wait for both writes: a Slack error never loses a paid KYB file.","height":800,"width":980,"color":7},"id":"f57dc2ab-54db-4cff-a2c0-25b78fc5bcfb","name":"Section 7","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[6970,-210]},{"parameters":{"content":"## 8. Then alert on new stops and reviews\n\nSlack alerts only on a new stop or review, after the sheet is written. Slack errors never stop the run. A supplier set back to approve with an undated sanctions match gets a full file each run while listed.","height":800,"width":720,"color":7},"id":"47be8478-e3bd-4af7-a929-a01e0a23022b","name":"Section 8","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[8010,-810]},{"parameters":{"content":"## 9. A weekly summary with the real cost\n\nCounts by outcome, rows skipped, suppliers not checked and why, the change check calls, and the credits really charged, read from the X-Credits-Charged header, KYB files against their cap.","height":800,"width":720,"color":7},"id":"87620c40-5ce3-4736-a68f-1732d0290b0d","name":"Section 9","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[8010,-10]},{"parameters":{"rule":{"interval":[{"field":"weeks","triggerAtDay":[1],"triggerAtHour":7}]}},"id":"4f44c7f6-3a5c-4986-a060-47e0110db415","name":"Every Monday at 07:00","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[0,-200]},{"parameters":{},"id":"a31b7746-9f1b-4276-ae52-d5559b8a060a","name":"Run the check now","type":"n8n-nodes-base.manualTrigger","typeVersion":1,"position":[0,200]},{"parameters":{"assignments":{"assignments":[{"id":"setting-fullCheckEveryDays","name":"fullCheckEveryDays","value":30,"type":"number"},{"id":"setting-maxKybSpendPerRun","name":"maxKybSpendPerRun","value":5,"type":"number"},{"id":"setting-changeTypesForFullCheck","name":"changeTypesForFullCheck","value":"procedure_collective, procedure_conciliation, decision_tribunal_commerce, cessation_activite, radiation, modification, inscription_liste_sanctions","type":"string"},{"id":"setting-linesPerIdentityCall","name":"linesPerIdentityCall","value":50,"type":"number"},{"id":"setting-companiesPerKybCall","name":"companiesPerKybCall","value":100,"type":"number"},{"id":"setting-waitBeforeRetrySeconds","name":"waitBeforeRetrySeconds","value":30,"type":"number"}]},"options":{}},"id":"532a6d79-9158-4cd2-a8db-5830c7eaa395","name":"Your settings","type":"n8n-nodes-base.set","typeVersion":3.4,"position":[260,0]},{"parameters":{"operation":"read","documentId":{"__rl":true,"mode":"list","value":""},"sheetName":{"__rl":true,"mode":"list","value":""},"options":{}},"id":"0f0cce4c-a1b8-459c-afc2-cc6831f66220","name":"Read the supplier list","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[520,0],"alwaysOutputData":true},{"parameters":{"mode":"runOnceForAllItems","jsCode":"// One row per supplier. A row is kept only when its identifier is one the\n// change check reads: a SIREN (9 digits), a SIRET (14 digits), a French VAT\n// number (FR and 11 digits) or name|postcode. Sirenic refuses a whole call\n// for one malformed line, so such rows are skipped here and named in the\n// weekly summary. Keep the Identifier column as plain text: a SIREN typed\n// as a number loses its leading zeros (they are put back below).\nconst isDay = (value) => /^\\d{4}-\\d{2}-\\d{2}$/.test(String(value ?? '').trim());\nfunction lineOf(cell) {\n  if (typeof cell === 'number') {\n    const digits = String(cell);\n    return digits.length <= 9 ? digits.padStart(9, '0') : digits.padStart(14, '0');\n  }\n  return String(cell ?? '').replace(/;/g, ' ').replace(/\\s+/g, ' ').trim();\n}\nfunction readable(line) {\n  const bar = line.lastIndexOf('|');\n  if (bar >= 0) {\n    const name = line.slice(0, bar).replace(/\\p{Cf}/gu, '').replace(/\\s+/g, ' ').trim();\n    return /^\\d{4,5}$/.test(line.slice(bar + 1).trim()) && name !== '' && [...name].length <= 100 && /[\\p{L}\\p{N}]/u.test(name);\n  }\n  const compact = line.replace(/[\\s.\\-]/g, '');\n  if (/^\\d+$/.test(compact)) return compact.length === 9 || compact.length === 14;\n  return /^FR\\d{11}$/i.test(compact);\n}\n\nconst suppliers = [];\nconst skipped = [];\nconst seen = new Map();\nfor (const item of $input.all()) {\n  const row = item.json;\n  if (Object.keys(row).length === 0) continue;\n  const where = row.row_number ? 'row ' + row.row_number : 'a row';\n  const line = lineOf(row['Identifier']);\n  if (line === '') { skipped.push(where + ': no identifier'); continue; }\n  if (!readable(line)) { skipped.push(where + ': not a SIREN, SIRET, French VAT number or name|postcode'); continue; }\n  const key = line.includes('|') ? line.toLowerCase() : line.replace(/[\\s.\\-]/g, '').toUpperCase();\n  if (seen.has(key)) { skipped.push(where + ': same identifier as ' + seen.get(key)); continue; }\n  seen.set(key, where);\n  const lastChecked = isDay(row['Last checked']) ? String(row['Last checked']).trim() : '';\n  const lastFullCheck = isDay(row['Last full check']) ? String(row['Last full check']).trim() : '';\n  suppliers.push({\n    supplier: String(row['Supplier'] ?? ''),\n    identifier: String(row['Identifier']),\n    line,\n    siren: String(row['SIREN'] ?? ''),\n    previousDecision: String(row['Decision'] ?? ''),\n    previousReasons: String(row['Reasons'] ?? ''),\n    lastChecked,\n    lastFullCheck,\n    since: lastChecked || lastFullCheck,\n  });\n}\nreturn [{ json: { suppliers, skipped } }];\n"},"id":"6e991d03-a542-4532-aae8-2376777d2a8e","name":"Clean up the list","type":"n8n-nodes-base.code","typeVersion":2,"position":[780,0]},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"two-suppliers","leftValue":"={{ $json.suppliers.length }}","rightValue":2,"operator":{"type":"number","operation":"gte"}}],"combinator":"and"},"options":{}},"id":"23a8ce79-cf01-4a5c-a530-674aace9c68a","name":"At least two suppliers?","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[1040,0]},{"parameters":{"select":"channel","channelId":{"__rl":true,"mode":"list","value":""},"text":"=:information_source: *Supplier check: nothing to check this week.* {{ $json.suppliers.length }} supplier(s) with a usable identifier, and the change check needs at least two. Nothing was called and nothing was billed.{{ $json.skipped.length ? '\\nRows skipped: ' + $json.skipped.join('; ') : '' }}","otherOptions":{}},"id":"ef160778-10e7-41fa-aec5-d0b602d998e9","name":"Say there is nothing to check","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1040,400],"onError":"continueRegularOutput"},{"parameters":{"mode":"runOnceForAllItems","jsCode":"// GET /v1/identite/lot reads 2 to 300 lines per call, and a call of up to\n// 50 lines stays in the free quota of a verified account. Lines are\n// split into balanced calls of at most linesPerIdentityCall (kept within\n// 2 to 300, and about 9000 bytes), never a call of one line.\n// Each call reads the changes since the oldest check of its suppliers, so\n// nothing is missed. Suppliers are sorted by their last check first (the\n// oldest first, new ones together at the end, the sheet order otherwise):\n// the start date of a call is then that of nearly each of its lines, and a\n// supplier left behind no longer pulls its neighbours' window into the past.\nconst settings = $('Your settings').first().json;\nconst suppliers = [...$input.first().json.suppliers].sort((a, b) => (a.since === b.since ? 0 : !a.since ? 1 : !b.since ? -1 : a.since < b.since ? -1 : 1));\nconst max = Math.min(300, Math.max(2, Math.floor(Number(settings.linesPerIdentityCall) || 50)));\nconst bytes = suppliers.reduce((total, s) => total + encodeURIComponent(s.line).length + 3, 0);\nconst count = Math.max(1, Math.min(Math.max(Math.ceil(suppliers.length / max), Math.ceil(bytes / 9000)), Math.floor(suppliers.length / 2)));\nconst calls = [];\nlet start = 0;\nfor (let i = 0; i < count; i++) {\n  const size = Math.floor(suppliers.length / count) + (i < suppliers.length % count ? 1 : 0);\n  const group = suppliers.slice(start, start + size);\n  start += size;\n  const since = group.map((s) => s.since).filter(Boolean).sort()[0] ?? '';\n  calls.push({ json: { call: i + 1, calls: count, suppliers: group, query: encodeURIComponent(group.map((s) => s.line).join(';')), since } });\n}\nreturn calls;\n"},"id":"e86c4f7e-1a8f-4525-a715-593d02b67906","name":"Build the change check calls","type":"n8n-nodes-base.code","typeVersion":2,"position":[1300,0]},{"parameters":{"batchSize":1,"options":{}},"id":"91e26efd-dea1-4b6c-ae73-42b2246cbce1","name":"One change check call at a time","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[1560,0]},{"parameters":{"url":"=https://api.sirenic.eu/v1/identite/lot?lignes={{ $json.query }}{{ $json.since ? '&depuis=' + $json.since : '' }}","authentication":"genericCredentialType","genericAuthType":"httpHeaderAuth","options":{"response":{"response":{"fullResponse":true,"neverError":true}},"timeout":240000}},"id":"e01affd0-fb24-4f48-ab03-dbe9ae376a1d","name":"Check identity and changes","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1820,200],"onError":"continueRegularOutput"},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"answer-change-check","leftValue":"={{ typeof $json.statusCode === 'number' && $json.statusCode !== 429 && $json.statusCode !== 503 }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}},"id":"35ccf6d2-7dc5-44ea-a040-83030811459f","name":"Answer received? (change check)","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[2080,200]},{"parameters":{"amount":"={{ $('Your settings').first().json.waitBeforeRetrySeconds }}","unit":"seconds"},"id":"7b8e9298-a836-4cb5-a910-bb9883bed920","name":"Wait, then retry the change check","type":"n8n-nodes-base.wait","typeVersion":1.1,"position":[2340,400]},{"parameters":{"url":"=https://api.sirenic.eu/v1/identite/lot?lignes={{ $('One change check call at a time').item.json.query }}{{ $('One change check call at a time').item.json.since ? '&depuis=' + $('One change check call at a time').item.json.since : '' }}","authentication":"genericCredentialType","genericAuthType":"httpHeaderAuth","options":{"response":{"response":{"fullResponse":true,"neverError":true}},"timeout":240000}},"id":"e976fd6d-16a9-4663-a7e7-4cb8bfbeff11","name":"Check identity and changes again","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[2600,400],"onError":"continueRegularOutput"},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"answer-after-retry-change-check","leftValue":"={{ typeof $json.statusCode === 'number' && $json.statusCode !== 429 && $json.statusCode !== 503 }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}},"id":"ce5b2020-b192-414d-a81e-6f9fddde5fe5","name":"Answer received after the retry? (change check)","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[2860,400]},{"parameters":{"mode":"runOnceForEachItem","jsCode":"// Still no usable answer after one retry; these suppliers are checked again\n// on the next run. Sirenic busy (429 or 503): nothing was billed, unless\n// the first try got no answer. No answer at all (network error or timeout,\n// no status): the call may have been served, so the summary asks to check\n// the credits of the account.\nconst call = $('One change check call at a time').item.json;\nconst noAnswer = typeof $json.statusCode !== 'number';\nconst firstNoAnswer = typeof $('Check identity and changes').item.json.statusCode !== 'number';\nreturn { json: { ...call, statusCode: noAnswer ? null : $json.statusCode, noAnswer, firstNoAnswer, body: null, credits: 0, freeCallsLeft: null, gaveUp: true } };\n"},"id":"bd720fc3-89ee-432a-a783-c4cc85a65652","name":"Give up on this change check call","type":"n8n-nodes-base.code","typeVersion":2,"position":[3120,600]},{"parameters":{"mode":"runOnceForEachItem","jsCode":"// Keep the call with its answer: the next step reads them all at once. A\n// first try with no answer (network error or timeout) may have been served\n// and billed: it is noted, and the summary asks to check the credits.\nconst call = $('One change check call at a time').item.json;\nconst firstNoAnswer = typeof $('Check identity and changes').item.json.statusCode !== 'number';\nconst headers = $json.headers ?? {};\nreturn { json: { ...call, statusCode: $json.statusCode, body: $json.body ?? null, credits: Number(headers['x-credits-charged'] ?? 0), freeCallsLeft: headers['x-free-quota-remaining'] ?? null, gaveUp: false, firstNoAnswer } };\n"},"id":"3cce4f29-ebd0-453e-a3a6-9a9131920974","name":"Keep the change check answer","type":"n8n-nodes-base.code","typeVersion":2,"position":[3120,200]},{"parameters":{"mode":"runOnceForAllItems","jsCode":"// Every answer of the change check, supplier by supplier:\n// - ceased at Sirene: stop, no KYB file needed;\n// - a name to confirm, not found, not public or not a valid SIREN: review;\n// - new, changed since its last check (or a strong match on a sanctions\n//   list that dates no listing), or full check older than\n//   fullCheckEveryDays: a full KYB file is due (changes first, then new\n//   suppliers, then the oldest), within maxKybSpendPerRun; a BODACC notice\n//   published before the day of the last full KYB file was read by that\n//   file and calls for no other one;\n// - changes not read this run (the batch ran out of time), or a block that\n//   serves one of your change types not read in full (a source down, a stock\n//   older than the window): not checked, checked again next run, never read\n//   as no change;\n// - otherwise: no change seen, nothing more is billed.\n// Officers, and sanctions on officers, are read only by the full KYB file:\n// that is why it runs again every fullCheckEveryDays. A block the change\n// check could not read is never read as nothing new.\nconst settings = $('Your settings').first().json;\nconst clean = $('Clean up the list').first().json;\nconst PRICE_BATCH = 0.105; // GET /v1/kyb/batch, per company\nconst PRICE_SINGLE = 0.15; // GET /v1/kyb/{siren}\nconst NO_ANSWER = 'no answer from Sirenic (network error or timeout) even after one retry; check the credits of your account';\nconst FIRST_TRY_NO_ANSWER = 'the first try got no answer (network error or timeout) and may have been billed: check the credits of your account';\nconst now = new Date();\nconst today = now.toISOString().slice(0, 10);\nconst checkedAt = now.toISOString();\nconst ageInDays = (day) => Math.floor((Date.parse(today) - Date.parse(day)) / 86400000);\nconst alertFor = (decision, previous) => (['stop', 'review'].includes(decision) && decision !== previous ? decision : '');\n\nconst base = (s) => ({ ...s, company: s.company ?? '', decision: s.previousDecision, reasons: s.previousReasons, newDecision: false, checked: false, whatRan: '', alert: '', checkedAt, screening: '', completeness: '', missingBlocks: '', notRead: '', next: 'done' });\nconst notChecked = (s, why) => ({ ...base(s), whatRan: 'not checked: ' + why });\n// A checked supplier is dated by the end of the window read, never by\n// today: a change dated after it and published later is read on the next\n// run. The window Sirenic gives (fenetre.jusqu_au) ends on the oldest day\n// its blocks can see, ceased or not included: read from the monthly Sirene\n// stock, it can stay on the day of that stock for weeks, and a court\n// decision already seen would then buy a new full file every week. So the\n// end is the oldest day seen by the blocks of the call that serve your\n// change types (servedUntil, below), never before the window Sirenic gives.\n// No window read (a call with no start date, or a line not resolved): the\n// last check date stays, today for a new one.\nconst readUntil = (s, line, servedUntil) => {\n  const window = line.changements?.fenetre?.jusqu_au;\n  const end = window && servedUntil > window ? servedUntil : window;\n  return end && end > s.since ? end : s.since || today;\n};\nconst settled = (s, line, until, decision, reasons) => ({ ...base(s), siren: line.siren ?? s.siren, company: line.denomination ?? '', decision, reasons, newDecision: true, checked: true, whatRan: 'change check: ' + reasons, lastChecked: until, alert: alertFor(decision, s.previousDecision) });\nconst unchanged = (s, line, until, note) => ({ ...base(s), siren: line.siren, company: line.denomination ?? '', checked: true, whatRan: note, lastChecked: until });\n// The change types that call for a full KYB file (Your settings).\nconst listedTypes = String(settings.changeTypesForFullCheck ?? '').split(',').map((t) => t.trim()).filter(Boolean);\n// A supplier due for a full KYB file keeps the end of the window read, for\n// the date written once its file is served. When a block that serves one of\n// your change types was not read in full (keepDate), it keeps its last check\n// date instead (today for a new one): the full file reads the same sources,\n// so that block is read again from there on the next run.\nconst due = (s, line, until, priority, why, keepDate) => ({ ...base(s), siren: line.siren, company: line.denomination ?? '', next: 'kyb', priority, why, readUntil: keepDate ? s.since || today : until });\n// The blocks the change check reads, and the change types each one serves\n// (Sirenic's block definitions).\nconst TYPES_BY_BLOCK = {\"bodacc\":[\"procedure_collective\",\"procedure_conciliation\",\"radiation\",\"vente_cession\",\"creation\",\"immatriculation\",\"modification\",\"depot_des_comptes\",\"annonce_diverse\"],\"decisions_tribunaux_commerce\":[\"decision_tribunal_commerce\"],\"marches_publics\":[\"marche_public_attribue\"],\"etablissements\":[\"etablissement_ouvert\",\"etablissement_ferme\"],\"cessation\":[\"cessation_activite\"],\"agrements_arcep\":[\"agrement_obtenu\",\"agrement_retire\"],\"agrements_eiopa\":[\"agrement_obtenu\",\"agrement_retire\"],\"agrements_eba\":[\"agrement_obtenu\",\"agrement_retire\"],\"sanctions\":[\"inscription_liste_sanctions\"],\"sanctions_listes_non_datees\":[\"inscription_liste_sanctions\"],\"certifications_rge\":[\"certification_rge_obtenue\",\"certification_rge_echue\"],\"representants_interets\":[\"inscription_representant_interets\",\"desinscription_representant_interets\"]};\n// A block this template does not know yet is taken as serving your types.\nconst serves = (bloc) => !Object.hasOwn(TYPES_BY_BLOCK, bloc) || TYPES_BY_BLOCK[bloc].some((t) => listedTypes.includes(t));\n// The change types only the BODACC serves (insolvency, radiation, register\n// changes such as officers or head office): a full KYB file reads its notices.\nconst BODACC_ONLY = [...new Set(Object.values(TYPES_BY_BLOCK).flat())].filter((t) => Object.entries(TYPES_BY_BLOCK).every(([bloc, types]) => bloc === 'bodacc' || !types.includes(t)));\n// Ceased or not is read on the line itself (etat) on every run: a monthly\n// Sirene stock older than the window never hides a cessation.\nconst READ_ON_EVERY_RUN = ['cessation'];\n// The end of the window read, from the blocks of the call (changements.blocs,\n// the oldest day each one saw for its lines): the oldest of those that serve\n// your change types, ceased or not left out.\nconst servedEnd = (changes) => (changes?.blocs ?? []).filter((b) => typeof b.couverture_jusqu_au === 'string' && serves(b.bloc) && !READ_ON_EVERY_RUN.includes(b.bloc)).map((b) => b.couverture_jusqu_au).sort()[0];\n// A strong match on a sanctions list is served without a date when the list\n// dates no listing (OFAC, French asset freezes): it can never fall in the\n// window, and is never read as nothing new. A supplier in review or stopped\n// gets no new file for it; one set back to approve by hand gets a full file on\n// every run while the match lasts (a namesake is settled by a person).\nconst SANCTIONS = 'inscription_liste_sanctions';\nconst undatedListing = (b) => (TYPES_BY_BLOCK[b.bloc] ?? []).includes(SANCTIONS) && !['indisponible', 'perime'].includes(b.etat) && (b.raisons_lecture_partielle ?? []).length === 0;\nconst listingWhy = (b) => (b.bloc === 'sanctions_listes_non_datees' ? 'possible listing on an undated sanctions list' : 'possible sanctions listing with no listing date');\n\nfunction decide(s, line, stale, servedUntil) {\n  const until = readUntil(s, line, servedUntil);\n  switch (line.resolution) {\n    case 'resolu': break;\n    case 'a_confirmer': return settled(s, line, until, 'review', 'name and postcode match several companies, confirm the SIREN: ' + (line.candidats ?? []).map((c) => c.siren + ' ' + c.denomination).join(', '));\n    case 'introuvable': return settled(s, line, until, 'review', 'no company found for this name and postcode');\n    case 'siren_invalide': return settled(s, line, until, 'review', 'not a valid SIREN (wrong check digit)');\n    case 'siren_inconnu': return settled(s, line, until, 'review', 'SIREN not in the Sirene register');\n    case 'non_diffusible': return settled(s, line, until, 'review', 'not public in the Sirene register (' + line.motif_omission + '), check this supplier by hand');\n    default: return settled(s, line, until, 'review', 'identity not resolved (' + line.resolution + ')');\n  }\n  if (line.etat === 'cesse') return settled(s, line, until, 'stop', 'company ceased at Sirene' + (line.date_cessation ? ' on ' + line.date_cessation : ''));\n  const changes = line.changements;\n  const incomplete = changes?.blocs_incomplets ?? [];\n  const bodacc = incomplete.find((b) => b.bloc === 'bodacc');\n  if (bodacc) return notChecked(s, bodacc.etat === 'indisponible' ? 'insolvency notices could not be read this run (BODACC unavailable), checked again next run' : 'insolvency notices could not be read in full this run (BODACC read in part), checked again next run');\n  // A possible listing with no date: a full KYB file screens the company\n  // again, unless it is already in review or stopped.\n  const undated = incomplete.filter(undatedListing);\n  // A block that serves one of your change types, not read in full (a source\n  // down, a stock older than the window): its changes may be missing. A\n  // supplier due for a full file keeps its date; any other one is not checked.\n  const missed = [\n    ...incomplete.filter((b) => !undated.includes(b)).map((b) => ({ bloc: b.bloc, why: [b.etat, b.motif, ...(b.raisons_lecture_partielle ?? [])].filter(Boolean).join(', ') })),\n    ...stale.map((b) => ({ bloc: b.bloc, why: 'stock as of ' + b.date_photo })),\n  ].filter((b) => !READ_ON_EVERY_RUN.includes(b.bloc) && serves(b.bloc));\n  const keepDate = missed.length > 0;\n  if (!s.lastFullCheck) return due(s, line, until, 2, 'new supplier', keepDate);\n  // A BODACC notice is dated by its publication: published before the day\n  // of the last full KYB file, it was read by that file and calls for no\n  // other one. The window read still stays on the day of a monthly stock for\n  // weeks when one of your change types is read from it (establishments, from\n  // the Sirene stock; public contracts, from the DECP stock; RGE\n  // certifications, from the RGE stock), and the change check then lists the\n  // same notice on every run.\n  const seenByFile = (d) => d.bloc === 'bodacc' && d.date < s.lastFullCheck;\n  const recent = (changes?.dernieres ?? []).filter((d) => !s.since || d.date >= s.since);\n  const listed = recent.filter((d) => listedTypes.includes(d.type) && !seenByFile(d));\n  // Only the latest changes are listed: a listed type counted beyond them is\n  // a change too, but only when the oldest listed one is in this supplier's\n  // window (the call reads from the oldest check of its suppliers, so the\n  // counts may start before this supplier's last check). Counted beyond them,\n  // a change is older than the oldest listed one: a type only the BODACC\n  // serves was read by the last full file when that one predates it.\n  const latest = changes?.dernieres ?? [];\n  const oldest = latest.map((d) => d.date).sort()[0];\n  const counted = latest.every((d) => !s.since || d.date >= s.since) ? listedTypes.filter((t) => (changes?.totaux_par_type?.[t] ?? 0) > latest.filter((d) => d.type === t).length) : [];\n  const beyondInFile = counted.filter((t) => BODACC_ONLY.includes(t) && oldest !== undefined && oldest < s.lastFullCheck);\n  const beyond = counted.filter((t) => !beyondInFile.includes(t));\n  const changed = changes?.verdict === 'nouveautes' ? [...listed.map((d) => d.type + ' on ' + d.date), ...beyond.map((t) => t + ' (more than listed)')] : [];\n  const inFile = changes?.verdict === 'nouveautes' ? [...recent.filter((d) => listedTypes.includes(d.type) && seenByFile(d)).map((d) => d.type + ' on ' + d.date), ...beyondInFile.map((t) => t + ' (more than listed)')] : [];\n  const listing = listedTypes.includes(SANCTIONS) ? [...new Set(undated.map(listingWhy))] : [];\n  const inReview = ['review', 'stop'].includes(s.previousDecision);\n  if (changed.length || (listing.length && !inReview)) return due(s, line, until, 1, [...(changed.length ? ['changed since ' + s.since + ': ' + changed.join(', ')] : []), ...(inReview ? [] : listing)].join('; '), keepDate);\n  if (ageInDays(s.lastFullCheck) > Number(settings.fullCheckEveryDays)) return due(s, line, until, 3, 'full check due, the last one was on ' + s.lastFullCheck, keepDate);\n  // Sirenic says why a line has no changes (motif_sans_changements): a line\n  // the batch had no time left to read is not \"no change\".\n  if (!changes) return notChecked(s, 'changes not read this run (' + (line.motif_sans_changements ?? 'not served') + '), checked again next run');\n  // Not due: a block not read in full leaves the supplier not checked; it\n  // keeps its date, checked again next run.\n  if (missed.length) return notChecked(s, 'changes not read in full this run: ' + missed.map((b) => b.bloc + ' (' + b.why + ')').join(', ') + '; checked again next run');\n  // Read in part: the blocks this line did not read in full. A stock older\n  // than the window that serves one of your change types is in missed above;\n  // any other one (none of your types reads it, or ceased or not, read on the\n  // line) leaves nothing you check unread, and is not listed, whether the\n  // call says it once for all its lines (stocks_perimes) or the line says it\n  // (stock_perime: another line of the call did not read that stock).\n  const partly = incomplete.filter((b) => b.motif !== 'stock_perime' && (!listing.length || !undated.includes(b))).map((b) => b.bloc);\n  const others = recent.filter((d) => !listedTypes.includes(d.type));\n  const seen = others.length || inFile.length ? 'no listed change since ' + s.since + (others.length ? ' (also seen: ' + others.map((d) => d.type + ' on ' + d.date).join(', ') + ')' : '') : 'no change seen since ' + s.since;\n  const notes = [...(inFile.length ? ['already in the full file of ' + s.lastFullCheck + ': ' + inFile.join(', ')] : []), ...listing.map((why) => why + ', already ' + s.previousDecision), ...(partly.length ? ['read in part: ' + partly.join(', ')] : [])];\n  return unchanged(s, line, until, seen + (notes.length ? ' (' + notes.join('; ') + ')' : ''));\n}\n\nconst results = [];\nlet identityCredits = 0;\nlet freeCallsLeft = null;\nlet noCredits = false;\n// Calls that got no answer at least once: each may have been served and\n// billed, so the summary asks to check the credits.\nlet unansweredCalls = 0;\nfor (const { json: answer } of $input.all()) {\n  identityCredits += Number(answer.credits) || 0;\n  if (answer.freeCallsLeft !== null && answer.freeCallsLeft !== undefined) freeCallsLeft = Number(answer.freeCallsLeft);\n  if (answer.noAnswer || answer.firstNoAnswer) unansweredCalls++;\n  if (answer.statusCode !== 200) {\n    if (answer.statusCode === 402) noCredits = true;\n    const billed = answer.firstNoAnswer ? '; ' + FIRST_TRY_NO_ANSWER : ', nothing was billed';\n    const why = answer.gaveUp && answer.noAnswer\n      ? NO_ANSWER\n      : answer.statusCode === 402\n        ? 'credits or free quota used up (HTTP 402)' + billed\n        : answer.gaveUp\n          ? 'Sirenic busy (HTTP ' + answer.statusCode + ') even after one retry' + billed\n          : 'the change check was refused (HTTP ' + answer.statusCode + (answer.body?.error ? ', ' + answer.body.error : '') + ')' + billed;\n    for (const s of answer.suppliers) results.push(notChecked(s, why));\n    continue;\n  }\n  const lines = answer.body.lignes;\n  const stale = answer.body.changements?.stocks_perimes ?? [];\n  const servedUntil = servedEnd(answer.body.changements);\n  answer.suppliers.forEach((s, i) => {\n    const line = lines[i].doublon_de ? lines[lines[i].doublon_de - 1] : lines[i];\n    results.push(decide(s, line, stale, servedUntil));\n  });\n}\n\n// Full KYB files: changes first, then new suppliers, then the oldest full\n// check; each SIREN once, in balanced calls of at most companiesPerKybCall,\n// within maxKybSpendPerRun, in credits (one SIREN alone costs PRICE_SINGLE, a\n// batch PRICE_BATCH per company). The cap counts KYB files only: the change\n// check above is free within the free quota of a verified account, then\n// billed by the line. After a 402, no file is asked for.\nconst perCall = Math.max(1, Math.min(100, Math.floor(Number(settings.companiesPerKybCall) || 100)));\nfunction split(list) {\n  const count = Math.ceil(list.length / perCall);\n  const out = [];\n  let start = 0;\n  for (let i = 0; i < count; i++) {\n    const size = Math.floor(list.length / count) + (i < list.length % count ? 1 : 0);\n    out.push(list.slice(start, start + size));\n    start += size;\n  }\n  return out;\n}\nconst cost = (list) => split(list).reduce((total, call) => total + (call.length === 1 ? PRICE_SINGLE : call.length * PRICE_BATCH), 0);\n// The cap must be a number of credits, 0 or more: missing, empty, not a\n// number or negative, no KYB file is bought, and the run says so.\nconst capSetting = settings.maxKybSpendPerRun;\nconst cap = typeof capSetting === 'number' ? capSetting : typeof capSetting === 'string' && capSetting.trim() !== '' ? Number(capSetting) : NaN;\nconst capSet = Number.isFinite(cap) && cap >= 0;\n// Changes first, then new suppliers, then the oldest full check; at equal\n// priority, the oldest full check first (never the order of the sheet).\nconst dueList = results.filter((r) => r.next === 'kyb').sort((a, b) => a.priority - b.priority || (a.lastFullCheck || '').localeCompare(b.lastFullCheck || ''));\nconst sirens = [...new Set(dueList.map((r) => r.siren))];\nlet kept = noCredits || !capSet ? 0 : sirens.length;\nwhile (kept > 0 && Math.round(cost(sirens.slice(0, kept)) * 1000) > Math.round(cap * 1000)) kept--;\nconst callOf = new Map(split(sirens.slice(0, kept)).flatMap((call, i) => call.map((siren) => [siren, i + 1])));\nfor (const r of dueList) {\n  if (callOf.has(r.siren)) {\n    r.kybCall = callOf.get(r.siren);\n    continue;\n  }\n  const notBought = noCredits\n    ? ', but credits or free quota are used up (HTTP 402)'\n    : !capSet\n      ? ', not bought: KYB spending cap not set (maxKybSpendPerRun in Your settings: a number of credits, 0 or more)'\n      : ', deferred: the KYB file spending cap of ' + cap + ' credits per run is reached';\n  Object.assign(r, notChecked(r, 'full check due (' + r.why + ')' + notBought));\n}\n\n// A change type in Your settings that no block of the change check serves\n// (a typo, say) would never call for a full file: the summary names it.\nconst unservedTypes = listedTypes.filter((t) => !Object.values(TYPES_BY_BLOCK).some((types) => types.includes(t)));\nconst run = { identityCredits, identityCalls: $input.all().length, unansweredCalls, kybCap: capSet ? cap : null, freeCallsLeft, skipped: clean.skipped, unservedTypes };\n// The planned calls keep the priority order: changes first.\nconst planned = dueList.filter((r) => r.next === 'kyb').sort((a, b) => a.kybCall - b.kybCall);\nreturn [...planned, ...results.filter((r) => r.next !== 'kyb')].map((r) => ({ json: { ...r, run } }));\n"},"id":"abde3803-a8f5-486c-a1eb-846cf1c37636","name":"Decide who needs a full check","type":"n8n-nodes-base.code","typeVersion":2,"position":[3380,0]},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"needs-kyb","leftValue":"={{ $json.next === 'kyb' }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}},"id":"2e3a8886-4adc-4459-aa2d-16a13cf0c4dd","name":"Needs a full KYB file?","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[3640,0]},{"parameters":{"mode":"runOnceForAllItems","jsCode":"// One item per KYB call, as planned by the previous step: each SIREN once,\n// with every supplier that shares it.\nconst calls = new Map();\nfor (const { json: s } of $input.all()) {\n  if (!calls.has(s.kybCall)) calls.set(s.kybCall, { call: s.kybCall, sirens: [], suppliers: [] });\n  const call = calls.get(s.kybCall);\n  if (!call.sirens.includes(s.siren)) call.sirens.push(s.siren);\n  call.suppliers.push(s);\n}\nreturn [...calls.values()].map((call) => ({ json: { ...call, calls: calls.size } }));\n"},"id":"371a6e10-e43e-48e2-adc3-05ad7f1f0c5f","name":"Build the KYB calls","type":"n8n-nodes-base.code","typeVersion":2,"position":[3900,0]},{"parameters":{"batchSize":1,"options":{}},"id":"7c7d8670-5c11-4c74-a818-011ceb3bbec5","name":"One KYB call at a time","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[4160,0]},{"parameters":{"url":"=https://api.sirenic.eu/v1/kyb/{{ $json.sirens.length > 1 ? 'batch?sirens=' + $json.sirens.join(',') : $json.sirens[0] }}","authentication":"genericCredentialType","genericAuthType":"httpHeaderAuth","options":{"response":{"response":{"fullResponse":true,"neverError":true}},"timeout":330000}},"id":"df5d54ce-0690-4da6-a80a-6a94399c1aee","name":"Get the KYB files","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[4420,200],"onError":"continueRegularOutput"},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"answer-kyb","leftValue":"={{ typeof $json.statusCode === 'number' && $json.statusCode !== 429 && $json.statusCode !== 503 }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}},"id":"50e8722b-2590-4c02-aecd-be8c870dc31e","name":"Answer received? (KYB)","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[4680,200]},{"parameters":{"amount":"={{ $('Your settings').first().json.waitBeforeRetrySeconds }}","unit":"seconds"},"id":"78b6c7f9-2bb8-4bcb-a5da-026e3b9d1c5d","name":"Wait, then retry the KYB call","type":"n8n-nodes-base.wait","typeVersion":1.1,"position":[4940,400]},{"parameters":{"url":"=https://api.sirenic.eu/v1/kyb/{{ $('One KYB call at a time').item.json.sirens.length > 1 ? 'batch?sirens=' + $('One KYB call at a time').item.json.sirens.join(',') : $('One KYB call at a time').item.json.sirens[0] }}","authentication":"genericCredentialType","genericAuthType":"httpHeaderAuth","options":{"response":{"response":{"fullResponse":true,"neverError":true}},"timeout":330000}},"id":"589c9334-77b1-4b02-aa25-d3da87dbef34","name":"Get the KYB files again","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[5200,400],"onError":"continueRegularOutput"},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"answer-after-retry-kyb","leftValue":"={{ typeof $json.statusCode === 'number' && $json.statusCode !== 429 && $json.statusCode !== 503 }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}},"id":"1b0ad1ce-0550-483f-af5b-dcaecd17e3be","name":"Answer received after the retry? (KYB)","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[5460,400]},{"parameters":{"mode":"runOnceForEachItem","jsCode":"// Still no usable answer after one retry; these suppliers are checked again\n// on the next run. Sirenic busy (429 or 503): nothing was billed, unless\n// the first try got no answer. No answer at all (network error or timeout,\n// no status): the call may have been served, so the summary asks to check\n// the credits of the account.\nconst call = $('One KYB call at a time').item.json;\nconst noAnswer = typeof $json.statusCode !== 'number';\nconst firstNoAnswer = typeof $('Get the KYB files').item.json.statusCode !== 'number';\nreturn { json: { ...call, statusCode: noAnswer ? null : $json.statusCode, noAnswer, firstNoAnswer, body: null, credits: 0, freeCallsLeft: null, gaveUp: true } };\n"},"id":"7c188264-ab33-4354-ae32-e6651ff88c79","name":"Give up on this KYB call","type":"n8n-nodes-base.code","typeVersion":2,"position":[5720,600]},{"parameters":{"mode":"runOnceForEachItem","jsCode":"// Keep the call with its answer: the next step reads them all at once. A\n// first try with no answer (network error or timeout) may have been served\n// and billed: it is noted, and the summary asks to check the credits.\nconst call = $('One KYB call at a time').item.json;\nconst firstNoAnswer = typeof $('Get the KYB files').item.json.statusCode !== 'number';\nconst headers = $json.headers ?? {};\nreturn { json: { ...call, statusCode: $json.statusCode, body: $json.body ?? null, credits: Number(headers['x-credits-charged'] ?? 0), freeCallsLeft: headers['x-free-quota-remaining'] ?? null, gaveUp: false, firstNoAnswer } };\n"},"id":"790f6251-a952-455a-ae8e-e04fea2c1d66","name":"Keep the KYB answer","type":"n8n-nodes-base.code","typeVersion":2,"position":[5720,200]},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"batch-refused-kyb","leftValue":"={{ $json.statusCode === 400 && Array.isArray($json.body?.sirens_refuses) && $json.body.sirens_refuses.length > 0 }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}},"id":"48c9daff-1a7b-4656-a217-a26e89e7f2c8","name":"Batch refused for some SIREN? (KYB)","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[4940,0]},{"parameters":{"mode":"runOnceForEachItem","jsCode":"// The batch was refused whole, and nothing billed: some SIREN are not legal\n// persons under full disclosure (a sole trader, a company under restricted\n// diffusion, a group without legal personality), and Sirenic serves them no\n// KYB file. Their suppliers go to review; the call is sent once more without\n// them (one SIREN left costs the single file price).\nconst call = $('One KYB call at a time').item.json;\nconst refused = $json.body.sirens_refuses;\nconst out = new Set(refused.map((r) => r.siren));\nreturn { json: { ...call, sirens: call.sirens.filter((siren) => !out.has(siren)), refused } };\n"},"id":"581871e4-d29e-4d5a-ab2b-7fcadf1e1091","name":"Leave out the refused SIREN","type":"n8n-nodes-base.code","typeVersion":2,"position":[5200,0]},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"siren-left-kyb","leftValue":"={{ $json.sirens.length > 0 }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}},"id":"cc7b8cc0-4eb6-4d5d-ae06-0062a64bcaca","name":"Any SIREN left? (KYB)","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[5460,0]},{"parameters":{"url":"=https://api.sirenic.eu/v1/kyb/{{ $json.sirens.length > 1 ? 'batch?sirens=' + $json.sirens.join(',') : $json.sirens[0] }}","authentication":"genericCredentialType","genericAuthType":"httpHeaderAuth","options":{"response":{"response":{"fullResponse":true,"neverError":true}},"timeout":330000}},"id":"db02a95f-d31c-483d-a43d-5bf348399cc3","name":"Get the KYB files without them","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[4420,-200],"onError":"continueRegularOutput"},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"answer-kyb-without-them","leftValue":"={{ typeof $json.statusCode === 'number' && $json.statusCode !== 429 && $json.statusCode !== 503 }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}},"id":"5ed30a57-50eb-44cf-ac0c-5fe27c41cfec","name":"Answer received? (KYB, without them)","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[4680,-200]},{"parameters":{"amount":"={{ $('Your settings').first().json.waitBeforeRetrySeconds }}","unit":"seconds"},"id":"4714d3d5-c5de-4b46-ad78-7582354941b2","name":"Wait, then retry without them","type":"n8n-nodes-base.wait","typeVersion":1.1,"position":[4940,-400]},{"parameters":{"url":"=https://api.sirenic.eu/v1/kyb/{{ $('Leave out the refused SIREN').item.json.sirens.length > 1 ? 'batch?sirens=' + $('Leave out the refused SIREN').item.json.sirens.join(',') : $('Leave out the refused SIREN').item.json.sirens[0] }}","authentication":"genericCredentialType","genericAuthType":"httpHeaderAuth","options":{"response":{"response":{"fullResponse":true,"neverError":true}},"timeout":330000}},"id":"d8407b73-902a-41c6-a154-376cd44888f3","name":"Get the KYB files without them again","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[5200,-400],"onError":"continueRegularOutput"},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"answer-after-retry-kyb-without-them","leftValue":"={{ typeof $json.statusCode === 'number' && $json.statusCode !== 429 && $json.statusCode !== 503 }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}},"id":"a4c02079-939a-4f98-af7d-ea0765973446","name":"Answer received after the retry? (KYB, without them)","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[5460,-400]},{"parameters":{"mode":"runOnceForEachItem","jsCode":"// Keep the call with the answer sent without the refused SIREN (after the\n// same single retry on 429 or 503), or with the refusal alone when none was\n// left. The refused ones go on to the next step, with the SIREN this answer\n// was asked for: a relaunch refused in turn names its own. A try with no\n// answer may have been served and billed: it is noted.\nconst call = $('One KYB call at a time').item.json;\nconst leftOut = $('Leave out the refused SIREN').item.json;\nconst firstNoAnswer = typeof $('Get the KYB files').item.json.statusCode !== 'number';\nif (leftOut.sirens.length === 0) {\n  return { json: { ...call, statusCode: 400, body: null, credits: 0, freeCallsLeft: null, gaveUp: false, firstNoAnswer, refused: leftOut.refused, asked: [] } };\n}\nconst noAnswer = typeof $json.statusCode !== 'number';\nconst answered = !noAnswer && $json.statusCode !== 429 && $json.statusCode !== 503;\nconst relaunchFirstNoAnswer = typeof $('Get the KYB files without them').item.json.statusCode !== 'number';\nconst headers = $json.headers ?? {};\nreturn { json: { ...call, statusCode: noAnswer ? null : $json.statusCode, body: answered ? ($json.body ?? null) : null, credits: Number(headers['x-credits-charged'] ?? 0), freeCallsLeft: headers['x-free-quota-remaining'] ?? null, gaveUp: !answered, noAnswer, firstNoAnswer: firstNoAnswer || (relaunchFirstNoAnswer && !noAnswer), refused: leftOut.refused, asked: leftOut.sirens } };\n"},"id":"47252fd0-6e3e-44cf-ac0a-20e3ba947aed","name":"Keep the KYB answer without them","type":"n8n-nodes-base.code","typeVersion":2,"position":[5720,-200]},{"parameters":{"mode":"runOnceForAllItems","jsCode":"// Back to one item per supplier. A batch answers one entry per SIREN asked;\n// a single SIREN gets its KYB file itself. An error is never billed.\nconst today = new Date().toISOString().slice(0, 10);\n// Sirenic serves a KYB file to legal persons under full disclosure only: a\n// refused SIREN goes to review with its reason, dated, never billed. The\n// relaunch can be refused in turn (a SIREN only the registry says is a sole\n// trader): its refused ones join the first, and there is no third call.\nconst NOT_COVERED = {\"entreprise_individuelle_non_couverte\":\"no KYB file for a sole trader, or a legal person not proven by its legal category\",\"diffusion_partielle_non_couverte\":\"no KYB file for a company under restricted diffusion\",\"groupement_sans_personnalite_morale_non_couvert\":\"no KYB file for a group without legal personality\"};\nfunction refusedOf(answer) {\n  const asked = answer.asked ?? answer.sirens ?? [];\n  const now = answer.statusCode === 400 && Array.isArray(answer.body?.sirens_refuses)\n    ? answer.body.sirens_refuses\n    : answer.statusCode === 400 && NOT_COVERED[answer.body?.error] && asked.length === 1\n      ? [{ siren: asked[0], code: answer.body.error }]\n      : [];\n  return new Map([...(Array.isArray(answer.refused) ? answer.refused : []), ...now].map((r) => [r.siren, r.code]));\n}\nfunction notCovered(s, code) {\n  const reasons = NOT_COVERED[code] ?? NOT_COVERED.entreprise_individuelle_non_couverte;\n  return { decision: 'review', reasons, newDecision: true, checked: true, whatRan: 'no KYB file (' + s.why + '): ' + reasons + ', never billed', lastChecked: s.readUntil || today, lastFullCheck: today, alert: s.previousDecision === 'review' ? '' : 'review' };\n}\nconst INCOMPLETE = 'Incomplete identity: the French Sirene register does not publish the administrative status or legal category of this company, so nothing is built on it and retrying will not help until it does. Its identity profile is still served, with that caveat: GET /v1/entreprise/ followed by the SIREN.';\nconst UNKNOWN = 'No company with this SIREN in the French registry.';\nconst answers = $input.all().map((item) => item.json);\nconst kybCredits = Math.round(answers.reduce((total, a) => total + (Number(a.credits) || 0), 0) * 1000) / 1000;\n// KYB calls that got no answer at least once (each may have been billed).\nconst kybUnanswered = answers.filter((a) => a.noAnswer || a.firstNoAnswer).length;\n\nfunction noFile(s, answer, entry) {\n  const reviewed = (reasons) => ({ decision: 'review', reasons, newDecision: true, checked: true, whatRan: 'full KYB file (' + s.why + '): ' + reasons, lastChecked: s.readUntil || today, lastFullCheck: today, alert: s.previousDecision === 'review' ? '' : 'review' });\n  if (answer.statusCode === 200) return reviewed(entry?.motif === 'identite_incomplete' ? INCOMPLETE : UNKNOWN);\n  if (answer.statusCode === 404) return reviewed(answer.body?.error === 'identite_incomplete' ? INCOMPLETE : UNKNOWN);\n  // No answer at all: the call may have been served, never \"nothing was billed\".\n  if (answer.gaveUp && answer.noAnswer) return { whatRan: 'not checked: full check due (' + s.why + '), no answer from Sirenic (network error or timeout) even after one retry; check the credits of your account' };\n  const why = answer.statusCode === 402\n    ? 'credits or free quota used up (HTTP 402)'\n    : answer.gaveUp\n      ? 'Sirenic busy (HTTP ' + answer.statusCode + ') even after one retry'\n      : 'refused (HTTP ' + answer.statusCode + (answer.body?.error ? ', ' + answer.body.error : '') + ')';\n  // A first try with no answer may have been served: never \"nothing was billed\".\n  return { whatRan: 'not checked: full check due (' + s.why + '), ' + why + (answer.firstNoAnswer ? '; the first try got no answer (network error or timeout) and may have been billed: check the credits of your account' : ', nothing was billed') };\n}\n\nconst out = [];\nfor (const answer of answers) {\n  const entries = new Map();\n  if (answer.statusCode === 200) {\n    const list = Array.isArray(answer.body?.entreprises) ? answer.body.entreprises : [{ trouve: true, ...answer.body }];\n    for (const entry of list) entries.set(entry.siren, entry);\n  }\n  const refused = refusedOf(answer);\n  for (const s of answer.suppliers) {\n    if (refused.has(s.siren)) {\n      out.push({ json: { ...s, kybCredits, kybUnanswered, ...notCovered(s, refused.get(s.siren)) } });\n      continue;\n    }\n    const entry = entries.get(s.siren);\n    out.push({ json: entry?.trouve === true ? { ...s, kybCredits, kybUnanswered, body: entry } : { ...s, kybCredits, kybUnanswered, ...noFile(s, answer, entry) } });\n  }\n}\nreturn out;\n"},"id":"1a9cb603-3ff2-4625-a8e6-0d7d4099c3a5","name":"One KYB file per supplier","type":"n8n-nodes-base.code","typeVersion":2,"position":[5980,0]},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"file-served","leftValue":"={{ Boolean($json.body) }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}},"id":"c65fec91-fe5d-40ea-aaff-471f5ec52eec","name":"Was a KYB file served?","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[6240,0]},{"parameters":{"mode":"runOnceForEachItem","jsCode":"// One KYB file in, one decision out: approve, review or stop.\n// Every rule reads a documented field of GET /v1/kyb/{siren}.\n// Reading rules served by the API: https://api.sirenic.eu/v1/lecture\n// A file is approved only when every block these rules read was served and\n// read in full: a missing or unavailable block is never read as nothing to\n// report, so the file goes to a human.\nconst kyb = $json.body;\nconst reasons = [];\n\n// A company that has ceased trading cannot open an account.\nconst status = kyb.identite?.etat_administratif;\nconst ceased = Boolean(status) && status !== 'actif';\nif (ceased) reasons.push(`company ceased (Sirene status: ${status})`);\nif (!status) reasons.push('administrative status unknown');\n\n// Insolvency history from the BODACC: it counts only when it was read in full.\n// When the BODACC is down, the block says \"indisponible\", with neither\n// coverage nor list of proceedings.\nconst bodacc = kyb.alertes_bodacc ?? {};\nconst historyRead = bodacc.couverture?.procedures_collectives_completes === true && Array.isArray(bodacc.procedures_collectives);\nif (!historyRead) {\n  reasons.push(bodacc.couverture?.procedures_collectives_completes === false\n    ? 'insolvency history not fully read'\n    : `insolvency history not read (BODACC block: ${bodacc.statut ?? 'no coverage served'})`);\n}\n\n// Insolvency proceedings: a human reads the latest judgment, because a\n// recovery plan keeps the company trading. Some notices are filings, not\n// judgments (a statement of debts): the latest JUDGMENT wins.\nconst proceedings = Array.isArray(bodacc.procedures_collectives) ? bodacc.procedures_collectives : [];\nif (proceedings.length > 0) {\n  const judgments = proceedings.filter((notice) => /^jugement/i.test(notice.jugement?.nature ?? ''));\n  const dated = (notice) => String(notice.jugement?.date ?? notice.date_parution ?? '');\n  const latest = [...(judgments.length > 0 ? judgments : proceedings)].sort((a, b) => dated(b).localeCompare(dated(a)))[0];\n  reasons.push(`${proceedings.length} insolvency notice(s), latest judgment: ${latest.jugement?.nature ?? 'unknown'} (${dated(latest)})`);\n}\n\n// Officers: served by the registry, then each one screened (below).\nconst officersServed = Array.isArray(kyb.identite?.dirigeants) && kyb.identite.dirigeants.length > 0;\nif (!officersServed) reasons.push('officers not served (missing block: dirigeants)');\n\n// Sanctions: a match is a name match to verify, never a decision.\n// rapprochements_partiels_seulement counts in no verdict (API reading rule);\n// any other status goes to a human.\nconst screening = kyb.criblage_sanctions ?? {};\nconst screeningClear = ['aucune_correspondance', 'rapprochements_partiels_seulement'].includes(screening.statut);\nif (screening.statut === 'correspondances_a_verifier') {\n  const targets = (screening.cibles ?? [])\n    .filter((target) => (target.correspondances ?? []).length > 0)\n    .map((target) => `${target.cible} (${target.role === 'dirigeant' ? 'officer' : 'company'})`);\n  reasons.push(`sanctions matches to verify: ${targets.join(', ')}`);\n} else if (screening.statut === 'aucune_correspondance_partielle') {\n  reasons.push(`screening incomplete, lists unavailable: ${(screening.listes_absentes ?? []).join(', ')}`);\n} else if (!screeningClear) {\n  reasons.push(`sanctions screening not conclusive (status: ${screening.statut ?? 'not served'})`);\n}\nconst unscreened = screening.cibles_non_criblees;\nconst allScreened = Array.isArray(unscreened) && unscreened.length === 0;\nif (!Array.isArray(unscreened)) reasons.push('screening of every officer not confirmed (cibles_non_criblees not served)');\nelse if (unscreened.length > 0) reasons.push(`${unscreened.length} officer(s) not screened`);\n\n// Blocks not read, or read only in part: the review message names them.\nconst screeningRead = ['aucune_correspondance', 'rapprochements_partiels_seulement', 'correspondances_a_verifier'].includes(screening.statut) && allScreened;\nconst notRead = new Set((kyb.blocs_manquants ?? []).map((block) => block.bloc));\nif (!status) notRead.add('identite');\nif (!historyRead) notRead.add('alertes_bodacc');\nif (!officersServed) notRead.add('dirigeants');\nif (!screeningRead) notRead.add('criblage_sanctions');\n\n// Approve only a complete, clean file: everything else goes to a human.\nconst complete = status === 'actif' && historyRead && proceedings.length === 0 && officersServed && screeningClear && allScreened;\n\nreturn {\n  json: {\n    checkedAt: new Date().toISOString(),\n    siren: kyb.siren,\n    company: kyb.identite?.denomination ?? '',\n    decision: ceased ? 'stop' : complete ? 'approve' : 'review',\n    reasons: reasons.join('; '),\n    screening: screening.statut ?? '',\n    completeness: kyb.score_completude ?? '',\n    missingBlocks: (kyb.blocs_manquants ?? []).map((block) => block.bloc).join(', '),\n    notRead: [...notRead].join(', '),\n  },\n};\n"},"id":"78b371cc-e626-4708-a831-0ede93f90f57","name":"Decide: approve, review or stop","type":"n8n-nodes-base.code","typeVersion":2,"position":[6500,0]},{"parameters":{"mode":"runOnceForEachItem","jsCode":"// The rules above read the KYB file only. Put the supplier back and compare\n// with the decision already in the sheet: a new stop or a new review raises\n// a Slack alert; every check is logged.\nconst { body, ...supplier } = $('One KYB file per supplier').item.json;\nconst today = new Date().toISOString().slice(0, 10);\nconst file = $json;\nconst alert = ['stop', 'review'].includes(file.decision) && file.decision !== supplier.previousDecision ? file.decision : '';\nreturn {\n  json: {\n    ...supplier,\n    company: file.company || supplier.company,\n    decision: file.decision,\n    reasons: file.reasons,\n    newDecision: true,\n    checked: true,\n    whatRan: 'full KYB file (' + supplier.why + ')',\n    // The end of the change window read; the file itself is dated today.\n    lastChecked: supplier.readUntil || today,\n    lastFullCheck: today,\n    alert,\n    checkedAt: file.checkedAt,\n    screening: file.screening,\n    completeness: String(file.completeness),\n    missingBlocks: file.missingBlocks,\n    notRead: file.notRead,\n  },\n};\n"},"id":"2142dd54-9f80-4235-a698-9257fa2e67ec","name":"Compare with the last decision","type":"n8n-nodes-base.code","typeVersion":2,"position":[6760,0]},{"parameters":{"mode":"append","numberInputs":3},"id":"88a7cba1-7e0d-4591-a11a-dc74dac6504b","name":"All suppliers of this run","type":"n8n-nodes-base.merge","typeVersion":3.2,"position":[7020,0]},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"checked-this-run","leftValue":"={{ $json.checked }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}},"id":"8a1dee2e-fe7a-457a-a8b9-b2d7f16b21c3","name":"Checked this run?","type":"n8n-nodes-base.filter","typeVersion":2.2,"position":[7280,200]},{"parameters":{"operation":"update","documentId":{"__rl":true,"mode":"list","value":""},"sheetName":{"__rl":true,"mode":"list","value":""},"columns":{"mappingMode":"defineBelow","value":{"Supplier":"={{ $json.supplier }}","Identifier":"={{ $json.identifier }}","SIREN":"={{ $json.siren }}","Decision":"={{ $json.decision }}","Reasons":"={{ $json.reasons }}","Last run":"={{ $json.whatRan }}","Last checked":"={{ $json.lastChecked }}","Last full check":"={{ $json.lastFullCheck }}","Sirenic: source":"=Supplier re-screened against official French sources via Sirenic on {{ $now.toFormat('yyyy-MM-dd') }}: https://api.sirenic.eu/en?utm_source=template&utm_campaign=w13"},"matchingColumns":["Identifier"],"schema":[{"id":"Supplier","displayName":"Supplier","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Identifier","displayName":"Identifier","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"SIREN","displayName":"SIREN","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Decision","displayName":"Decision","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Reasons","displayName":"Reasons","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Last run","displayName":"Last run","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Last checked","displayName":"Last checked","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Last full check","displayName":"Last full check","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Sirenic: source","displayName":"Sirenic: source","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true}],"attemptToConvertTypes":false,"convertFieldsToString":false},"options":{"cellFormat":"RAW"}},"id":"82b0183c-e493-46c4-abff-6f9b532c0e88","name":"Update the supplier list","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[7540,200],"retryOnFail":true,"maxTries":3,"waitBetweenTries":5000},{"parameters":{"operation":"append","documentId":{"__rl":true,"mode":"list","value":""},"sheetName":{"__rl":true,"mode":"list","value":""},"columns":{"mappingMode":"defineBelow","value":{"Checked at":"={{ $json.checkedAt }}","Supplier":"={{ $json.supplier }}","Identifier":"={{ $json.identifier }}","SIREN":"={{ $json.siren }}","Company":"={{ $json.company }}","Decision":"={{ $json.decision }}","Reasons":"={{ $json.reasons }}","What ran":"={{ $json.whatRan }}","Screening":"={{ $json.screening }}","Completeness":"={{ $json.completeness }}","Missing blocks":"={{ $json.missingBlocks }}","Sirenic: source":"=Supplier re-screened against official French sources via Sirenic on {{ $now.toFormat('yyyy-MM-dd') }}: https://api.sirenic.eu/en?utm_source=template&utm_campaign=w13"},"matchingColumns":[],"schema":[{"id":"Checked at","displayName":"Checked at","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Supplier","displayName":"Supplier","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Identifier","displayName":"Identifier","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"SIREN","displayName":"SIREN","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Company","displayName":"Company","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Decision","displayName":"Decision","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Reasons","displayName":"Reasons","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"What ran","displayName":"What ran","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Screening","displayName":"Screening","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Completeness","displayName":"Completeness","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Missing blocks","displayName":"Missing blocks","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true},{"id":"Sirenic: source","displayName":"Sirenic: source","required":false,"defaultMatch":false,"display":true,"type":"string","canBeUsedToMatch":true}],"attemptToConvertTypes":false,"convertFieldsToString":false},"options":{"cellFormat":"RAW"}},"id":"932942dd-1078-455e-a9e6-163dca1566c7","name":"Log the check in the audit sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[7540,400],"retryOnFail":true,"maxTries":3,"waitBetweenTries":5000},{"parameters":{"mode":"append","numberInputs":3},"id":"d07c3e93-f0d3-4aa0-a77b-349616c67d21","name":"Wait for the sheet writes","type":"n8n-nodes-base.merge","typeVersion":3.2,"position":[7800,0]},{"parameters":{"mode":"runOnceForAllItems","jsCode":"// The sheet and the audit log are written (the step before waits for both):\n// now one alert per new stop or new review.\nreturn $('All suppliers of this run').all().filter((item) => item.json.alert).map((item) => ({ json: item.json }));\n"},"id":"d64a750c-832b-440f-a4bf-6c4f2ebb9790","name":"Pick the alerts","type":"n8n-nodes-base.code","typeVersion":2,"position":[8060,-400]},{"parameters":{"rules":{"values":[{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"alert-stop","leftValue":"={{ $json.alert }}","rightValue":"stop","operator":{"type":"string","operation":"equals"}}],"combinator":"and"},"renameOutput":true,"outputKey":"Stop"},{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"alert-review","leftValue":"={{ $json.alert }}","rightValue":"review","operator":{"type":"string","operation":"equals"}}],"combinator":"and"},"renameOutput":true,"outputKey":"Review"}]},"options":{}},"id":"9713f22f-7ef3-4cb8-a658-9f9d1eaeb1d0","name":"Route the alerts","type":"n8n-nodes-base.switch","typeVersion":3.2,"position":[8320,-400]},{"parameters":{"select":"channel","channelId":{"__rl":true,"mode":"list","value":""},"text":"=:no_entry: *Supplier stop: {{ $json.company || $json.supplier }}* (SIREN {{ $json.siren }})\nWhy: {{ $json.reasons }}\nBefore: {{ $json.previousDecision || 'new supplier' }}. Check open orders and invoices before paying.\nSupplier re-screened against official French sources via <https://api.sirenic.eu/en?utm_source=template&utm_campaign=w13|Sirenic> on {{ $now.toFormat('yyyy-MM-dd') }}","otherOptions":{}},"id":"6387127d-e4ba-4292-a36e-1e81da99eeec","name":"Alert: stop paying this supplier","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[8580,-600],"onError":"continueRegularOutput"},{"parameters":{"select":"channel","channelId":{"__rl":true,"mode":"list","value":""},"text":"=:mag: *Supplier review needed: {{ $json.company || $json.supplier }}* (SIREN {{ $json.siren || 'not resolved' }})\nWhy: {{ $json.reasons }}\n{{ $json.notRead ? \"Not read or read in part: \" + $json.notRead + \". \" : \"\" }}A block not read in full is never read as nothing to report, and a sanctions match is a name match to verify, never a decision.\nSupplier re-screened against official French sources via <https://api.sirenic.eu/en?utm_source=template&utm_campaign=w13|Sirenic> on {{ $now.toFormat('yyyy-MM-dd') }}","otherOptions":{}},"id":"256710e5-f7f5-4cc4-aeac-cac0c08003d2","name":"Ask compliance to review a supplier","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[8580,-200],"onError":"continueRegularOutput"},{"parameters":{"mode":"runOnceForAllItems","jsCode":"// One message for the week: what was checked, what changed, what could not\n// be checked and why, the change check calls, and the credits really charged\n// (X-Credits-Charged), the KYB files against their cap.\n// It runs once the sheet and the audit log are written (the step before\n// waits for both), and reads every supplier of the run.\nconst all = $('All suppliers of this run').all().map((item) => item.json);\nconst run = all[0]?.run ?? { identityCredits: 0, identityCalls: 0, kybCap: null, freeCallsLeft: null, skipped: [], unservedTypes: [] };\nconst round = (value) => Math.round(value * 1000) / 1000;\nconst kybCredits = round(Math.max(0, ...all.map((s) => Number(s.kybCredits) || 0)));\nconst byDecision = (list) => ['approve', 'review', 'stop'].map((d) => d + ' ' + list.filter((s) => s.decision === d).length).join(', ');\nconst full = all.filter((s) => s.checked && s.whatRan.startsWith('full KYB file'));\nconst settled = all.filter((s) => s.checked && s.newDecision && s.whatRan.startsWith('change check'));\nconst unchanged = all.filter((s) => s.checked && !s.newDecision);\nconst waiting = new Map();\nfor (const s of all.filter((x) => !x.checked)) waiting.set(s.whatRan, (waiting.get(s.whatRan) ?? 0) + 1);\nconst lines = ['*Weekly supplier check: ' + all.length + ' supplier(s)*'];\nif (full.length) lines.push('Full KYB files: ' + full.length + ' (' + byDecision(full) + ')');\nif (settled.length) lines.push('Settled by the change check: ' + settled.length + ' (' + byDecision(settled) + ')');\nif (unchanged.length) lines.push('No change seen: ' + unchanged.length);\nfor (const [why, n] of waiting) lines.push(n + ' ' + why);\n// A call with no answer, even once, may have been served and billed.\nconst unanswered = (Number(run.unansweredCalls) || 0) + Math.max(0, ...all.map((s) => Number(s.kybUnanswered) || 0));\nif (unanswered) lines.push(unanswered + ' call(s) to Sirenic got no answer at least once (network error or timeout) and may have been billed: check the credits of your account');\nif (unanswered || [...waiting.keys()].some((why) => why.includes('HTTP 402'))) lines.push('Credits and free quota: https://api.sirenic.eu/compte?utm_source=n8n&utm_campaign=w13-supplier-rescreening-sheets-slack');\nif (run.skipped.length) lines.push('Rows skipped: ' + run.skipped.join('; '));\nif ((run.unservedTypes ?? []).length) lines.push('Change types in Your settings that the change check never reports (check the spelling): ' + run.unservedTypes.join(', '));\nconst calls = run.identityCalls + (run.identityCalls === 1 ? ' call' : ' calls');\nlines.push('Credits charged: ' + round(run.identityCredits + kybCredits) + ' (change check ' + round(run.identityCredits) + ' over ' + calls + ', KYB files ' + kybCredits + (run.kybCap === null ? ' (spending cap not set)' : ' within a cap of ' + run.kybCap + ' credits per run') + ')' + (run.freeCallsLeft !== null ? '. Free calls left this month: ' + run.freeCallsLeft : '') + '.');\n// The source line is posted only when at least one supplier was checked.\nreturn [{ json: { summary: lines.join('\\n'), suppliersChecked: all.filter((s) => s.checked).length } }];\n"},"id":"3f087c4e-e169-466a-a091-0992a92f8d6a","name":"Sum up the run","type":"n8n-nodes-base.code","typeVersion":2,"position":[8060,400]},{"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"strict","version":2},"conditions":[{"id":"any-supplier-checked","leftValue":"={{ $json.suppliersChecked > 0 }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}},"id":"ad271383-1651-4b2a-a98d-8a13d93983a5","name":"Was any supplier checked?","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[8320,400]},{"parameters":{"select":"channel","channelId":{"__rl":true,"mode":"list","value":""},"text":"=:clipboard: {{ $json.summary }}\nSupplier re-screened against official French sources via <https://api.sirenic.eu/en?utm_source=template&utm_campaign=w13|Sirenic> on {{ $now.toFormat('yyyy-MM-dd') }}","otherOptions":{}},"id":"537c5740-c368-4916-aa09-83e379e606c0","name":"Post the weekly summary","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[8580,200],"onError":"continueRegularOutput"},{"parameters":{"select":"channel","channelId":{"__rl":true,"mode":"list","value":""},"text":"=:clipboard: {{ $json.summary }}","otherOptions":{}},"id":"5cc2fb25-c92d-4066-aa07-5dde2503b924","name":"Post the weekly summary, nothing checked","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[8580,600],"onError":"continueRegularOutput"}],"connections":{"Every Monday at 07:00":{"main":[[{"node":"Your settings","type":"main","index":0}]]},"Run the check now":{"main":[[{"node":"Your settings","type":"main","index":0}]]},"Your settings":{"main":[[{"node":"Read the supplier list","type":"main","index":0}]]},"Read the supplier list":{"main":[[{"node":"Clean up the list","type":"main","index":0}]]},"Clean up the list":{"main":[[{"node":"At least two suppliers?","type":"main","index":0}]]},"At least two suppliers?":{"main":[[{"node":"Build the change check calls","type":"main","index":0}],[{"node":"Say there is nothing to check","type":"main","index":0}]]},"Build the change check calls":{"main":[[{"node":"One change check call at a time","type":"main","index":0}]]},"One change check call at a time":{"main":[[{"node":"Decide who needs a full check","type":"main","index":0}],[{"node":"Check identity and changes","type":"main","index":0}]]},"Check identity and changes":{"main":[[{"node":"Answer received? (change check)","type":"main","index":0}]]},"Answer received? (change check)":{"main":[[{"node":"Keep the change check answer","type":"main","index":0}],[{"node":"Wait, then retry the change check","type":"main","index":0}]]},"Wait, then retry the change check":{"main":[[{"node":"Check identity and changes again","type":"main","index":0}]]},"Check identity and changes again":{"main":[[{"node":"Answer received after the retry? (change check)","type":"main","index":0}]]},"Answer received after the retry? (change check)":{"main":[[{"node":"Keep the change check answer","type":"main","index":0}],[{"node":"Give up on this change check call","type":"main","index":0}]]},"Give up on this change check call":{"main":[[{"node":"One change check call at a time","type":"main","index":0}]]},"Keep the change check answer":{"main":[[{"node":"One change check call at a time","type":"main","index":0}]]},"Decide who needs a full check":{"main":[[{"node":"Needs a full KYB file?","type":"main","index":0}]]},"Needs a full KYB file?":{"main":[[{"node":"Build the KYB calls","type":"main","index":0}],[{"node":"All suppliers of this run","type":"main","index":0}]]},"Build the KYB calls":{"main":[[{"node":"One KYB call at a time","type":"main","index":0}]]},"One KYB call at a time":{"main":[[{"node":"One KYB file per supplier","type":"main","index":0}],[{"node":"Get the KYB files","type":"main","index":0}]]},"Get the KYB files":{"main":[[{"node":"Answer received? (KYB)","type":"main","index":0}]]},"Answer received? (KYB)":{"main":[[{"node":"Batch refused for some SIREN? (KYB)","type":"main","index":0}],[{"node":"Wait, then retry the KYB call","type":"main","index":0}]]},"Wait, then retry the KYB call":{"main":[[{"node":"Get the KYB files again","type":"main","index":0}]]},"Get the KYB files again":{"main":[[{"node":"Answer received after the retry? (KYB)","type":"main","index":0}]]},"Answer received after the retry? (KYB)":{"main":[[{"node":"Batch refused for some SIREN? (KYB)","type":"main","index":0}],[{"node":"Give up on this KYB call","type":"main","index":0}]]},"Batch refused for some SIREN? (KYB)":{"main":[[{"node":"Leave out the refused SIREN","type":"main","index":0}],[{"node":"Keep the KYB answer","type":"main","index":0}]]},"Leave out the refused SIREN":{"main":[[{"node":"Any SIREN left? (KYB)","type":"main","index":0}]]},"Any SIREN left? (KYB)":{"main":[[{"node":"Get the KYB files without them","type":"main","index":0}],[{"node":"Keep the KYB answer without them","type":"main","index":0}]]},"Get the KYB files without them":{"main":[[{"node":"Answer received? (KYB, without them)","type":"main","index":0}]]},"Answer received? (KYB, without them)":{"main":[[{"node":"Keep the KYB answer without them","type":"main","index":0}],[{"node":"Wait, then retry without them","type":"main","index":0}]]},"Wait, then retry without them":{"main":[[{"node":"Get the KYB files without them again","type":"main","index":0}]]},"Get the KYB files without them again":{"main":[[{"node":"Answer received after the retry? (KYB, without them)","type":"main","index":0}]]},"Answer received after the retry? (KYB, without them)":{"main":[[{"node":"Keep the KYB answer without them","type":"main","index":0}],[{"node":"Keep the KYB answer without them","type":"main","index":0}]]},"Keep the KYB answer without them":{"main":[[{"node":"One KYB call at a time","type":"main","index":0}]]},"Give up on this KYB call":{"main":[[{"node":"One KYB call at a time","type":"main","index":0}]]},"Keep the KYB answer":{"main":[[{"node":"One KYB call at a time","type":"main","index":0}]]},"One KYB file per supplier":{"main":[[{"node":"Was a KYB file served?","type":"main","index":0}]]},"Was a KYB file served?":{"main":[[{"node":"Decide: approve, review or stop","type":"main","index":0}],[{"node":"All suppliers of this run","type":"main","index":2}]]},"Decide: approve, review or stop":{"main":[[{"node":"Compare with the last decision","type":"main","index":0}]]},"Compare with the last decision":{"main":[[{"node":"All suppliers of this run","type":"main","index":1}]]},"All suppliers of this run":{"main":[[{"node":"Checked this run?","type":"main","index":0},{"node":"Wait for the sheet writes","type":"main","index":0}]]},"Checked this run?":{"main":[[{"node":"Update the supplier list","type":"main","index":0},{"node":"Log the check in the audit sheet","type":"main","index":0}]]},"Update the supplier list":{"main":[[{"node":"Wait for the sheet writes","type":"main","index":1}]]},"Log the check in the audit sheet":{"main":[[{"node":"Wait for the sheet writes","type":"main","index":2}]]},"Wait for the sheet writes":{"main":[[{"node":"Pick the alerts","type":"main","index":0},{"node":"Sum up the run","type":"main","index":0}]]},"Pick the alerts":{"main":[[{"node":"Route the alerts","type":"main","index":0}]]},"Route the alerts":{"main":[[{"node":"Alert: stop paying this supplier","type":"main","index":0}],[{"node":"Ask compliance to review a supplier","type":"main","index":0}]]},"Sum up the run":{"main":[[{"node":"Was any supplier checked?","type":"main","index":0}]]},"Was any supplier checked?":{"main":[[{"node":"Post the weekly summary","type":"main","index":0}],[{"node":"Post the weekly summary, nothing checked","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}