Files
portal-de-verificacion-de-n…/portal-de-verificacion-de-nomina-tt.json
T

2020 lines
318 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"updatedAt": "2026-07-16T16:22:13.609Z",
"createdAt": "2026-07-14T14:51:32.549Z",
"id": "5AujMxduslftVg9z",
"name": "Portal de Verificación de Nómina - TT",
"description": "Automatiza el Portal de Verificación de Nómina de Trinidad y Tobago: recibe la nómina y los archivos bancarios, consulta y normaliza los empleados de BambooHR, consolida las diferentes fuentes bancarias, cruza empleados, cuentas y montos, genera y comparte el reporte de diferencias en Google Sheets, registra el resultado histórico en Supabase y devuelve el enlace final a la aplicación.",
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "nominatt-bamboo-test",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-512,
1424
],
"id": "670c388d-cd62-435c-a1fa-550e32dffc38",
"name": "Webhook",
"webhookId": "9c730860-7790-43a5-a3c0-bf5984ced244"
},
{
"parameters": {
"jsCode": "const item = $input.first();\n\nconst body = item.json.body || {};\nconst binary = item.binary || {};\n\nlet metadata = {};\n\ntry {\n metadata = typeof body.metadata === 'string'\n ? JSON.parse(body.metadata)\n : body.metadata || {};\n} catch (error) {\n metadata = {};\n}\n\nconst binaryKeys = Object.keys(binary);\n\nconst payrollKey = binaryKeys.find(\n (key) => key === 'payroll_file'\n);\n\nconst bankKeys = binaryKeys\n .filter((key) => key.startsWith('bank_files'))\n .sort();\n\nconst payrollFile = payrollKey\n ? {\n binary_key: payrollKey,\n file_name: binary[payrollKey].fileName,\n file_extension: binary[payrollKey].fileExtension,\n mime_type: binary[payrollKey].mimeType,\n file_size: binary[payrollKey].fileSize,\n }\n : null;\n\nconst bankFiles = bankKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key].fileName,\n file_extension: binary[key].fileExtension,\n mime_type: binary[key].mimeType,\n file_size: binary[key].fileSize,\n}));\n\nconst receivedCountry = String(\n metadata.country || ''\n).trim().toUpperCase();\n\nconst errors = [];\n\nif (!['TT', 'TTO'].includes(receivedCountry)) {\n errors.push(\n 'El país recibido no es Trinidad y Tobago.'\n );\n}\n\nif (!metadata.year) {\n errors.push('No se recibió el año del cruce.');\n}\n\nif (!metadata.month) {\n errors.push('No se recibió el mes del cruce.');\n}\n\nif (!metadata.period_type) {\n errors.push('No se recibió el tipo de quincena.');\n}\n\nif (!metadata.period_start || !metadata.period_end) {\n errors.push('No se recibió el período calculado.');\n}\n\nif (!payrollFile) {\n errors.push('No se recibió el archivo de nómina.');\n}\n\nif (bankFiles.length === 0) {\n errors.push(\n 'No se recibió ningún archivo CSV del banco.'\n );\n}\n\nconst normalizedMetadata = {\n ...metadata,\n country: 'TT',\n country_name: 'Trinidad y Tobago',\n source_app:\n metadata.source_app ||\n 'cruce-cuentas-glm-trinidad-tobago',\n payroll_file_name:\n metadata.payroll_file_name ||\n payrollFile?.file_name ||\n '',\n bank_file_names:\n metadata.bank_file_names ||\n bankFiles.map((file) => file.file_name),\n};\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n stage: 'entrada_tt_recibida',\n errors,\n metadata: normalizedMetadata,\n payroll_file: payrollFile,\n bank_files: bankFiles,\n summary: {\n payroll_files_count:\n payrollFile ? 1 : 0,\n bank_files_count: bankFiles.length,\n },\n },\n binary,\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-272,
1424
],
"id": "91d42eab-5010-4db8-aa38-c71e5fb49b37",
"name": "Preparar entrada app"
},
{
"parameters": {
"jsCode": "const input = $input.first();\nconst json = input.json || {};\nconst binary = input.binary || {};\n\nfunction parseCsvLine(line) {\n const result = [];\n let current = '';\n let insideQuotes = false;\n\n for (let index = 0; index < line.length; index++) {\n const character = line[index];\n const nextCharacter = line[index + 1];\n\n if (\n character === '\"' &&\n insideQuotes &&\n nextCharacter === '\"'\n ) {\n current += '\"';\n index += 1;\n continue;\n }\n\n if (character === '\"') {\n insideQuotes = !insideQuotes;\n continue;\n }\n\n if (character === ',' && !insideQuotes) {\n result.push(current.trim());\n current = '';\n continue;\n }\n\n current += character;\n }\n\n result.push(current.trim());\n return result;\n}\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['`-]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction isValidAccount(value) {\n const account = normalizeAccount(value);\n return (\n account.length >= 6 &&\n !/^0+$/.test(account)\n );\n}\n\nfunction parseMoney(value) {\n const cleaned = String(value ?? '')\n .replace(/TTD/gi, '')\n .replace(/TT\\$/gi, '')\n .replace(/\\$/g, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction getColumnIndex(headers, names) {\n const normalizedHeaders =\n headers.map(normalizeForCompare);\n\n for (const name of names) {\n const expected = normalizeForCompare(name);\n const index = normalizedHeaders.findIndex(\n (header) => header === expected\n );\n\n if (index >= 0) return index;\n }\n\n return -1;\n}\n\nconst bankKeys = Object.keys(binary)\n .filter((key) => key.startsWith('bank_files'))\n .sort();\n\nconst allBankRows = [];\nconst fileSummaries = [];\n\nfor (const key of bankKeys) {\n const file = binary[key];\n const buffer =\n await this.helpers.getBinaryDataBuffer(0, key);\n\n let text = buffer.toString('utf8');\n\n if (text.includes('\\uFFFD')) {\n text = buffer.toString('latin1');\n }\n\n const lines = text\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter(Boolean);\n\n const headerIndex = lines.findIndex((line) => {\n const normalized = normalizeForCompare(line);\n\n return (\n normalized.includes('identifier') &&\n normalized.includes('account number') &&\n normalized.includes('amount') &&\n normalized.includes('participant name')\n );\n });\n\n if (headerIndex < 0) {\n fileSummaries.push({\n file_name: file.fileName,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error:\n 'No se encontró el encabezado esperado del archivo bancario de Trinidad y Tobago.',\n });\n continue;\n }\n\n const headers = parseCsvLine(\n lines[headerIndex]\n ).map(normalizeText);\n\n const indexIdentifier = getColumnIndex(\n headers,\n ['Identifier']\n );\n const indexAccount = getColumnIndex(\n headers,\n ['Account Number']\n );\n const indexAccountType = getColumnIndex(\n headers,\n ['Account type']\n );\n const indexAmount = getColumnIndex(\n headers,\n ['Amount']\n );\n const indexInstitution = getColumnIndex(\n headers,\n ['Financial Institution ID']\n );\n const indexParticipantId = getColumnIndex(\n headers,\n ['Participant ID']\n );\n const indexParticipantName = getColumnIndex(\n headers,\n ['Participant Name']\n );\n const indexTransactionType = getColumnIndex(\n headers,\n ['TR Type']\n );\n const indexAddenda = getColumnIndex(\n headers,\n ['Addenda']\n );\n\n const rowsFromFile = [];\n\n for (\n let lineIndex = headerIndex + 1;\n lineIndex < lines.length;\n lineIndex++\n ) {\n const values = parseCsvLine(lines[lineIndex]);\n\n const identifier = normalizeText(\n indexIdentifier >= 0\n ? values[indexIdentifier]\n : ''\n ).toUpperCase();\n\n // T = transacción. C = fila de control/totales.\n if (identifier !== 'T') continue;\n\n const account = normalizeAccount(\n indexAccount >= 0\n ? values[indexAccount]\n : ''\n );\n\n const amount = roundMoney(\n parseMoney(\n indexAmount >= 0\n ? values[indexAmount]\n : ''\n )\n );\n\n const participantName = normalizeText(\n indexParticipantName >= 0\n ? values[indexParticipantName]\n : ''\n );\n\n if (amount <= 0 || !participantName) {\n continue;\n }\n\n const accountIsValid =\n isValidAccount(account);\n\n const groupKey = accountIsValid\n ? `ACCOUNT:${account}:TTD`\n : `ROW:${file.fileName}:${lineIndex + 1}:TTD`;\n\n const row = {\n source_file: file.fileName,\n row_number: lineIndex + 1,\n group_key: groupKey,\n account,\n raw_account: account,\n account_is_valid: accountIsValid,\n bank_name_file: participantName,\n bank_account_holder: '',\n participant_name: participantName,\n participant_id: normalizeText(\n indexParticipantId >= 0\n ? values[indexParticipantId]\n : ''\n ),\n financial_institution_id:\n normalizeText(\n indexInstitution >= 0\n ? values[indexInstitution]\n : ''\n ),\n account_type: normalizeText(\n indexAccountType >= 0\n ? values[indexAccountType]\n : ''\n ),\n transaction_type: normalizeText(\n indexTransactionType >= 0\n ? values[indexTransactionType]\n : ''\n ),\n reference: normalizeText(\n indexAddenda >= 0\n ? values[indexAddenda]\n : ''\n ),\n addenda: normalizeText(\n indexAddenda >= 0\n ? values[indexAddenda]\n : ''\n ),\n shipment_number: '',\n plan_number: '',\n amount,\n currency: 'TTD',\n status: 'Procesado',\n };\n\n rowsFromFile.push(row);\n allBankRows.push(row);\n }\n\n fileSummaries.push({\n file_name: file.fileName,\n ok: true,\n rows_count: rowsFromFile.length,\n total_amount: roundMoney(\n rowsFromFile.reduce(\n (sum, row) => sum + row.amount,\n 0\n )\n ),\n error: null,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of allBankRows) {\n const current =\n groupedMap.get(row.group_key) || {\n group_key: row.group_key,\n account: row.account,\n raw_account: row.raw_account,\n account_is_valid: row.account_is_valid,\n amount: 0,\n currency: 'TTD',\n transactions_count: 0,\n bank_name_files: new Set(),\n bank_account_holders: new Set(),\n source_files: new Set(),\n institution_ids: new Set(),\n source_rows: [],\n };\n\n current.amount = roundMoney(\n current.amount + row.amount\n );\n current.transactions_count += 1;\n\n if (row.bank_name_file) {\n current.bank_name_files.add(\n row.bank_name_file\n );\n }\n\n if (row.source_file) {\n current.source_files.add(row.source_file);\n }\n\n if (row.financial_institution_id) {\n current.institution_ids.add(\n row.financial_institution_id\n );\n }\n\n current.source_rows.push(row);\n groupedMap.set(row.group_key, current);\n}\n\nconst groupedByAccount = Array.from(\n groupedMap.values()\n).map((row) => {\n const names = Array.from(\n row.bank_name_files\n );\n\n return {\n ...row,\n bank_name_file: names[0] || '',\n bank_account_holder: '',\n bank_name_files: names,\n bank_account_holders: [],\n source_files: Array.from(\n row.source_files\n ),\n institution_ids: Array.from(\n row.institution_ids\n ),\n };\n});\n\nconst totalAmount = roundMoney(\n allBankRows.reduce(\n (sum, row) => sum + row.amount,\n 0\n )\n);\n\nreturn [\n {\n json: {\n ...json,\n stage: 'banco_tt_parseado',\n bank: {\n source:\n 'csv_ach_trinidad_tobago',\n files_count: bankKeys.length,\n valid_files_count:\n fileSummaries.filter(\n (file) => file.ok\n ).length,\n rows_count: allBankRows.length,\n grouped_accounts_count:\n groupedByAccount.length,\n total_amount: totalAmount,\n totals_by_currency: {\n TTD: totalAmount,\n },\n name_differences_count: 0,\n name_differences: [],\n file_summaries: fileSummaries,\n rows: allBankRows,\n grouped_by_account:\n groupedByAccount,\n },\n },\n binary,\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
64,
1120
],
"id": "25638e1d-310a-492a-b1db-b8814bf14344",
"name": "Parsear CSV banco TT"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "BICE"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
1520
],
"id": "d5f93467-cd0a-493b-973e-5abc9645ccd0",
"name": "Extract - BICE",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "Goldey Samuel"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
1696
],
"id": "94b7ba5c-45dd-4c34-93a9-1af811c81941",
"name": "Extract - Goldey Samuel",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "P&G"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
1856
],
"id": "6d91ab0f-1aab-4fd8-92ff-3f3dbeabed1d",
"name": "Extract - P&G",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "Whirlpool"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
2032
],
"id": "09716bc9-0431-433d-bd58-71a76a5bb31d",
"name": "Extract - Whirlpool",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "KAD"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
2208
],
"id": "27ab7829-5bfe-491d-9b8d-ce5dc90efb53",
"name": "Extract - KAD",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "GLM People"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
2368
],
"id": "5fcc84ba-9c13-4d98-815f-3e246f0f3fc0",
"name": "Extract - GLM People",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "GLM"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
2544
],
"id": "f5edbec8-7e3e-4ed2-9ca8-74374a302919",
"name": "Extract - GLM",
"retryOnFail": false
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
464,
1616
],
"id": "81c9547b-8ce5-4b83-acaf-87768239c463",
"name": "Merge Hojas TT 01-02"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
464,
1776
],
"id": "303f35ae-c7c3-4f79-8682-00ebbc23b6f9",
"name": "Merge Hojas TT 03"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
464,
1952
],
"id": "38ff1b61-1e0d-4896-b54c-44e47262f825",
"name": "Merge Hojas TT 04"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
464,
2112
],
"id": "2ccfeb6e-96a9-4107-b2ca-f4d2f34f2874",
"name": "Merge Hojas TT 05"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
464,
2288
],
"id": "058d4ed4-3ac8-4235-afdc-10a0e8355ef2",
"name": "Merge Hojas TT 06"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
464,
2464
],
"id": "39dc6b0c-2cf2-4728-9d71-ae60b943973f",
"name": "Merge Hojas TT 07"
},
{
"parameters": {
"jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['`-]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n if (\n value === null ||\n value === undefined ||\n value === ''\n ) {\n return '';\n }\n\n if (typeof value === 'number') {\n return String(Math.trunc(value));\n }\n\n return String(value)\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction parseMoney(value) {\n if (typeof value === 'number') {\n return Number.isFinite(value)\n ? value\n : 0;\n }\n\n const cleaned = String(value ?? '')\n .replace(/TTD/gi, '')\n .replace(/TT\\$/gi, '')\n .replace(/\\$/g, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction getValue(row, possibleKeys) {\n for (const key of possibleKeys) {\n const value = row[key];\n\n if (\n value !== undefined &&\n value !== null &&\n value !== ''\n ) {\n return value;\n }\n }\n\n const rowKeys = Object.keys(row || {});\n\n for (const expected of possibleKeys) {\n const normalizedExpected =\n normalizeForCompare(expected);\n\n const matchingKey = rowKeys.find(\n (key) =>\n normalizeForCompare(key) ===\n normalizedExpected\n );\n\n if (!matchingKey) continue;\n\n const value = row[matchingKey];\n\n if (\n value !== undefined &&\n value !== null &&\n value !== ''\n ) {\n return value;\n }\n }\n\n return '';\n}\n\nfunction getNodeRows(nodeName) {\n try {\n return $items(nodeName)\n .map((item) => item.json || {})\n .filter((row) => {\n if (row.error) return false;\n\n const text = JSON.stringify(\n row || {}\n ).toLowerCase();\n\n return !(\n text.includes(\n 'spreadsheet does not contain sheet'\n ) ||\n text.includes('no sheet')\n );\n });\n } catch (error) {\n return [];\n }\n}\n\nfunction validEmployeeName(value) {\n const name = normalizeText(value);\n const normalized = normalizeForCompare(name);\n\n if (!name) return false;\n if (/^[\\d.,\\s]+$/.test(name)) return false;\n\n const invalid = [\n 'total',\n 'subtotal',\n 'gran total',\n 'total general',\n 'variable',\n 'empleado',\n 'first name',\n 'nombre',\n 'diferencia',\n 'total dias',\n ];\n\n return !invalid.some(\n (token) =>\n normalized === token ||\n normalized.startsWith(`${token} `)\n );\n}\n\nfunction validAccount(value) {\n const account = normalizeAccount(value);\n\n return (\n account.length >= 6 &&\n !/^0+$/.test(account)\n );\n}\n\nconst sheetConfigs = [\n {\n node: 'Extract - BICE',\n sheet: 'BICE',\n },\n {\n node: 'Extract - Goldey Samuel',\n sheet: 'Goldey Samuel',\n },\n {\n node: 'Extract - P&G',\n sheet: 'P&G',\n },\n {\n node: 'Extract - Whirlpool',\n sheet: 'Whirlpool',\n },\n {\n node: 'Extract - KAD',\n sheet: 'KAD',\n },\n {\n node: 'Extract - GLM People',\n sheet: 'GLM People',\n },\n {\n node: 'Extract - GLM',\n sheet: 'GLM',\n },\n];\n\nconst payrollRows = [];\nconst noAccountRows = [];\nconst ignoredRows = [];\nconst sheetSummaries = [];\n\nfor (const config of sheetConfigs) {\n const sourceRows = getNodeRows(\n config.node\n );\n\n let validRows = 0;\n let noAccountCount = 0;\n let ignoredCount = 0;\n let sheetTotal = 0;\n\n sourceRows.forEach((sourceRow, index) => {\n const period = normalizeText(\n getValue(sourceRow, ['Periodo'])\n );\n\n const employeeName = normalizeText(\n getValue(sourceRow, [\n 'First Name',\n 'Nombre completo',\n 'Empleado',\n 'Name',\n ])\n );\n\n const account = normalizeAccount(\n getValue(sourceRow, [\n 'Account #',\n 'Account Number',\n 'Cuenta bancaria',\n 'Cuenta Bancaria',\n ])\n );\n\n const email = normalizeText(\n getValue(sourceRow, [\n 'EMAIL',\n 'Email',\n 'Correo',\n ])\n ).toLowerCase();\n\n const amount = roundMoney(\n parseMoney(\n getValue(sourceRow, [\n 'NETO A PAGAR',\n 'Neto a Pagar',\n 'Net Pay',\n ])\n )\n );\n\n const client = normalizeText(\n getValue(sourceRow, ['Cuenta'])\n );\n\n const rowNumber = index + 2;\n\n const normalized = {\n source_sheet: config.sheet,\n row_number: rowNumber,\n period,\n employee_name: employeeName,\n employee_number: null,\n account,\n email,\n client,\n payroll_amount: amount,\n currency: 'TTD',\n };\n\n if (\n !period ||\n !validEmployeeName(employeeName) ||\n amount <= 0 ||\n amount > 500000\n ) {\n ignoredRows.push({\n ...normalized,\n reason:\n !period\n ? 'period_empty'\n : !validEmployeeName(employeeName)\n ? 'invalid_employee_name'\n : amount <= 0\n ? 'amount_zero_or_invalid'\n : 'suspicious_large_amount',\n });\n\n ignoredCount += 1;\n return;\n }\n\n sheetTotal = roundMoney(\n sheetTotal + amount\n );\n\n if (!validAccount(account)) {\n noAccountRows.push({\n ...normalized,\n account: '',\n });\n\n noAccountCount += 1;\n return;\n }\n\n payrollRows.push(normalized);\n validRows += 1;\n });\n\n sheetSummaries.push({\n sheet: config.sheet,\n node: config.node,\n raw_rows_count: sourceRows.length,\n valid_rows_count: validRows,\n no_account_rows_count:\n noAccountCount,\n ignored_rows_count: ignoredCount,\n total_amount: sheetTotal,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of payrollRows) {\n const groupKey =\n `${row.account}:${row.currency}`;\n\n const current =\n groupedMap.get(groupKey) || {\n group_key: groupKey,\n account: row.account,\n employee_name: row.employee_name,\n employee_number: null,\n email: row.email,\n currency: 'TTD',\n payroll_amount: 0,\n rows_count: 0,\n source_sheets: new Set(),\n source_rows: [],\n };\n\n current.payroll_amount = roundMoney(\n current.payroll_amount +\n row.payroll_amount\n );\n\n current.rows_count += 1;\n\n if (!current.email && row.email) {\n current.email = row.email;\n }\n\n current.source_sheets.add(\n row.source_sheet\n );\n\n current.source_rows.push({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n account: row.account,\n amount: row.payroll_amount,\n employee_name: row.employee_name,\n });\n\n groupedMap.set(groupKey, current);\n}\n\nconst groupedByAccount = Array.from(\n groupedMap.values()\n).map((row) => ({\n ...row,\n source_sheets: Array.from(\n row.source_sheets\n ),\n}));\n\nconst totalAmount = roundMoney(\n payrollRows.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n ) +\n noAccountRows.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n )\n);\n\nreturn [\n {\n json: {\n payroll: {\n source:\n 'template_trinidad_tobago',\n sheets_count:\n sheetConfigs.length,\n sheet_summaries:\n sheetSummaries,\n raw_rows_count:\n sheetSummaries.reduce(\n (sum, sheet) =>\n sum + sheet.raw_rows_count,\n 0\n ),\n valid_rows_count:\n payrollRows.length,\n no_account_rows_count:\n noAccountRows.length,\n ignored_rows_count:\n ignoredRows.length,\n grouped_accounts_count:\n groupedByAccount.length,\n attached_supplements_count: 0,\n potential_supplements_count: 0,\n potential_supplements: [],\n unattached_supplements_count: 0,\n total_amount: totalAmount,\n totals_by_currency: {\n TTD: totalAmount,\n },\n rows: payrollRows,\n no_account_rows:\n noAccountRows,\n grouped_by_account:\n groupedByAccount,\n },\n debug_payroll: {\n attached_supplements: [],\n potential_supplements: [],\n unattached_supplements: [],\n ignored_rows_preview:\n ignoredRows.slice(0, 100),\n no_account_rows_preview:\n noAccountRows.slice(0, 50),\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
832,
2032
],
"id": "564cf732-be79-434c-b876-051fb7bcd698",
"name": "Normalizar Nómina TT"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
1088,
1376
],
"id": "6152c18c-460c-49e0-b513-bf95b2b30865",
"name": "Merge Banco + Nómina TT"
},
{
"parameters": {
"url": "https://glm.bamboohr.com/api/v1/employees",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "fields",
"value": "employeeNumber,firstName,middleName,lastName,preferredName,displayName,fullName1,fullName2,fullName3,fullName4,fullName5,status,employmentStatus,employmentHistoryStatus,hireDate,originalHireDate,terminationDate,location,country,includeInPayroll,workEmail,homeEmail,bestEmail"
},
{
"name": "page[limit]",
"value": "2500"
}
]
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
}
]
},
"options": {
"pagination": {
"pagination": {
"paginationMode": "responseContainsNextURL",
"nextURL": "={{ $response.body._links?.next?.href || '' }}",
"paginationCompleteWhen": "other",
"completeExpression": "={{ !$response.body._links?.next?.href }}",
"limitPagesFetched": true,
"maxRequests": 10
}
},
"timeout": 120000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
64,
896
],
"id": "83d1cefc-0284-4ea6-9d82-bc2224ed5559",
"name": "HTTP - Empleados BambooHR TT",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"credentials": {
"httpBasicAuth": {
"id": "7VrpNZ2jBLmiJ35q",
"name": "BambooHR GLM Full Access"
}
}
},
{
"parameters": {
"jsCode": "const inputItems = $input.all();\nconst base = $('Preparar entrada app').first().json || {};\nconst reconciliationData =\n $('Merge Banco + Nómina TT').first().json || {};\nconst metadata = base.metadata || {};\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalize(value) {\n return clean(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction unique(values) {\n return Array.from(\n new Set(\n values\n .map(clean)\n .filter(Boolean)\n )\n );\n}\n\nfunction parseDate(value) {\n const raw = clean(value);\n if (!raw) return null;\n\n const direct = raw.match(/^(\\d{4})-(\\d{2})-(\\d{2})/);\n if (direct) {\n return `${direct[1]}-${direct[2]}-${direct[3]}`;\n }\n\n const date = new Date(raw);\n if (Number.isNaN(date.getTime())) return null;\n\n return date.toISOString().slice(0, 10);\n}\n\nfunction parseBoolean(value) {\n if (typeof value === 'boolean') return value;\n\n const normalized = normalize(value);\n\n return [\n 'true',\n 'yes',\n 'si',\n 'sí',\n '1',\n 'y',\n ].includes(normalized);\n}\n\nfunction isTrinidadTobago(employee) {\n const country = normalize(employee.country);\n const location = normalize(\n employee.location ||\n employee.jobInformationLocation ||\n employee.jobLocation\n );\n\n return (\n country === 'tt' ||\n country === 'tto' ||\n country.includes('trinidad') ||\n country.includes('tobago') ||\n location === 'tt' ||\n location === 'tto' ||\n location.includes('trinidad') ||\n location.includes('tobago')\n );\n}\n\nfunction overlapsPeriod(\n hireDate,\n terminationDate,\n periodStart,\n periodEnd\n) {\n if (!periodStart || !periodEnd) return false;\n\n const hiredBeforeEnd =\n !hireDate || hireDate <= periodEnd;\n\n const notTerminatedBeforeStart =\n !terminationDate ||\n terminationDate >= periodStart;\n\n return hiredBeforeEnd && notTerminatedBeforeStart;\n}\n\nfunction collectPageObjects(value, pages) {\n if (!value) return;\n\n if (Array.isArray(value)) {\n for (const entry of value) {\n collectPageObjects(entry, pages);\n }\n return;\n }\n\n if (typeof value !== 'object') return;\n\n if (value.body && typeof value.body === 'object') {\n collectPageObjects(value.body, pages);\n return;\n }\n\n if (\n Array.isArray(value.data) ||\n Array.isArray(value.employees)\n ) {\n pages.push(value);\n return;\n }\n\n if (value.json && typeof value.json === 'object') {\n collectPageObjects(value.json, pages);\n }\n}\n\nconst pageObjects = [];\n\nfor (const item of inputItems) {\n collectPageObjects(item.json, pageObjects);\n}\n\nconst employeeMap = new Map();\nlet expectedTotal = 0;\nlet restrictedFields = 0;\n\nfor (const page of pageObjects) {\n const pageEmployees =\n Array.isArray(page.data)\n ? page.data\n : Array.isArray(page.employees)\n ? page.employees\n : [];\n\n const pageTotal = Number(\n page.meta?.total ||\n page.total ||\n 0\n );\n\n if (Number.isFinite(pageTotal)) {\n expectedTotal = Math.max(\n expectedTotal,\n pageTotal\n );\n }\n\n for (const employee of pageEmployees) {\n const key =\n clean(employee.employeeId || employee.id) ||\n clean(employee.employeeNumber) ||\n clean(employee.bestEmail).toLowerCase() ||\n [\n clean(employee.firstName),\n clean(employee.middleName),\n clean(employee.lastName),\n ].filter(Boolean).join('|').toLowerCase();\n\n if (!key) continue;\n\n employeeMap.set(key, employee);\n\n restrictedFields += Array.isArray(\n employee._restrictedFields\n )\n ? employee._restrictedFields.length\n : 0;\n }\n}\n\nconst rawEmployees = Array.from(\n employeeMap.values()\n);\n\n// Algunos perfiles pueden existir en BambooHR, pero tener vacío o\n// incorrecto el campo country/location. Para que no aparezcan como\n// \"Banco sin Bamboo\", se incorporan como candidatos únicamente cuando\n// su nombre coincide exactamente con un nombre del banco o la nómina\n// de esta misma ejecución.\nconst relevantNames = new Set();\n\nfunction addRelevantName(value) {\n const normalized = normalize(value);\n if (normalized) relevantNames.add(normalized);\n}\n\nfor (const row of reconciliationData.bank?.rows || []) {\n addRelevantName(\n row.bank_name_file ||\n row.participant_name ||\n row.bank_account_holder ||\n ''\n );\n}\n\nfor (\n const row of\n reconciliationData.bank?.grouped_by_account || []\n) {\n for (const name of row.bank_name_files || []) {\n addRelevantName(name);\n }\n for (const name of row.bank_account_holders || []) {\n addRelevantName(name);\n }\n addRelevantName(row.bank_name_file || '');\n addRelevantName(row.bank_account_holder || '');\n}\n\nfor (const row of reconciliationData.payroll?.rows || []) {\n addRelevantName(\n row.employee_name ||\n row.employee ||\n ''\n );\n}\n\nfor (\n const row of\n reconciliationData.payroll?.no_account_rows || []\n) {\n addRelevantName(\n row.employee_name ||\n row.employee ||\n ''\n );\n}\n\nconst periodStart = clean(metadata.period_start);\nconst periodEnd = clean(metadata.period_end);\n\nconst allNormalized = rawEmployees.map((employee) => {\n const firstName = clean(employee.firstName);\n const middleName = clean(employee.middleName);\n const lastName = clean(employee.lastName);\n const preferredName = clean(\n employee.preferredName\n );\n\n const constructedFullName = [\n firstName,\n middleName,\n lastName,\n ].filter(Boolean).join(' ');\n\n const aliases = unique([\n employee.displayName,\n employee.fullName1,\n employee.fullName2,\n employee.fullName3,\n employee.fullName4,\n employee.fullName5,\n constructedFullName,\n [preferredName, lastName]\n .filter(Boolean)\n .join(' '),\n [firstName, lastName]\n .filter(Boolean)\n .join(' '),\n ]);\n\n const hireDate = parseDate(\n employee.hireDate ||\n employee.originalHireDate\n );\n\n const terminationDate = parseDate(\n employee.terminationDate\n );\n\n const status = clean(\n employee.status ||\n employee.employmentStatus ||\n employee.employmentHistoryStatus\n );\n\n const employeeNumber = clean(\n employee.employeeNumber ||\n employee.employee_number\n );\n\n return {\n bamboo_id: clean(\n employee.employeeId ||\n employee.id\n ),\n employee_number: employeeNumber,\n first_name: firstName,\n middle_name: middleName,\n last_name: lastName,\n preferred_name: preferredName,\n full_name:\n clean(employee.displayName) ||\n clean(employee.fullName1) ||\n constructedFullName,\n aliases,\n normalized_aliases:\n aliases.map(normalize).filter(Boolean),\n status,\n hire_date: hireDate,\n termination_date: terminationDate,\n location: clean(\n employee.location ||\n employee.jobInformationLocation ||\n employee.jobLocation\n ),\n country: clean(employee.country),\n include_in_payroll:\n parseBoolean(employee.includeInPayroll),\n work_email:\n clean(employee.workEmail).toLowerCase(),\n home_email:\n clean(employee.homeEmail).toLowerCase(),\n best_email: clean(\n employee.bestEmail ||\n employee.workEmail ||\n employee.homeEmail\n ).toLowerCase(),\n exists_in_bamboo: true,\n overlaps_period: overlapsPeriod(\n hireDate,\n terminationDate,\n periodStart,\n periodEnd\n ),\n };\n});\n\n// El universo principal para “Banco sin Bamboo” son los perfiles\n// pertenecientes a Trinidad y Tobago. Se permite rescatar un perfil con\n// país/location incorrecto únicamente cuando:\n// 1) su nombre coincide exactamente con un nombre del banco o nómina,\n// 2) está Active, y\n// 3) se encontraba vigente dentro del período procesado.\n// Esto conserva perfiles activos con metadata de país incorrecta, pero evita\n// que un registro histórico de otro país o fuera del período produzca un\n// falso positivo. Ejemplo confirmado: Isaac St Bernard tiene un perfil\n// inactivo de República Dominicana, terminado antes del 16-06-2026; ese\n// registro no es válido para confirmar su presencia en BambooHR TT.\nconst trinidadTobagoEmployees =\n allNormalized\n .filter(isTrinidadTobago)\n .map((employee) => ({\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'trinidad_tobago_country_or_location',\n }));\n\nconst relevantEmployeesOutsideCountry =\n allNormalized.filter((employee) => {\n if (isTrinidadTobago(employee)) return false;\n\n return (\n employee.normalized_aliases || []\n ).some((alias) =>\n relevantNames.has(alias)\n );\n });\n\nconst eligibleRelevantEmployeesOutsideCountry =\n relevantEmployeesOutsideCountry\n .filter((employee) =>\n employee.overlaps_period === true &&\n normalize(employee.status) === 'active'\n )\n .map((employee) => ({\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'outside_country_exact_name_active_in_period',\n }));\n\nconst excludedRelevantEmployeesOutsideCountry =\n relevantEmployeesOutsideCountry\n .filter((employee) =>\n !(\n employee.overlaps_period === true &&\n normalize(employee.status) === 'active'\n )\n )\n .map((employee) => ({\n bamboo_id: employee.bamboo_id,\n employee_number: employee.employee_number,\n full_name: employee.full_name,\n country: employee.country,\n location: employee.location,\n status: employee.status,\n hire_date: employee.hire_date,\n termination_date:\n employee.termination_date,\n overlaps_period:\n employee.overlaps_period,\n exclusion_reason:\n employee.overlaps_period !== true\n ? 'outside_processing_period'\n : 'status_not_active',\n }));\n\nconst validationEmployeeMap = new Map();\n\nfor (const employee of [\n ...trinidadTobagoEmployees,\n ...eligibleRelevantEmployeesOutsideCountry,\n]) {\n const key =\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name);\n\n if (key) {\n validationEmployeeMap.set(key, employee);\n }\n}\n\nconst bambooValidationEmployees =\n Array.from(validationEmployeeMap.values());\n\nconst fetchedEmployeesCount =\n rawEmployees.length;\n\nconst fetchComplete =\n expectedTotal > 0\n ? fetchedEmployeesCount >= expectedTotal\n : (\n pageObjects.length > 0 &&\n !pageObjects.some(\n (page) =>\n Boolean(\n page?._links?.next?.href\n )\n )\n );\n\nconst errors = [];\n\nif (!pageObjects.length) {\n errors.push(\n 'BambooHR no devolvió páginas de empleados.'\n );\n}\n\nif (!fetchedEmployeesCount) {\n errors.push(\n 'BambooHR no devolvió empleados.'\n );\n}\n\nif (\n expectedTotal > 0 &&\n fetchedEmployeesCount < expectedTotal\n) {\n errors.push(\n `La descarga de BambooHR quedó incompleta: ` +\n `${fetchedEmployeesCount} de ${expectedTotal} empleados.`\n );\n}\n\nif (!trinidadTobagoEmployees.length) {\n errors.push(\n 'No se encontraron empleados de Trinidad y Tobago en BambooHR.'\n );\n}\n\nreturn [\n {\n json: {\n ...base,\n ok:\n Boolean(base.ok ?? true) &&\n errors.length === 0,\n stage:\n errors.length === 0\n ? 'bamboohr_tt_normalizado'\n : 'bamboohr_tt_incompleto',\n errors: [\n ...(Array.isArray(base.errors)\n ? base.errors\n : []),\n ...errors,\n ],\n bamboo: {\n source:\n 'bamboohr_list_employees_paginado',\n period_start: periodStart,\n period_end: periodEnd,\n pages_fetched: pageObjects.length,\n expected_total: expectedTotal,\n raw_employees_count:\n fetchedEmployeesCount,\n employees_count:\n allNormalized.length,\n trinidad_tobago_count:\n trinidadTobagoEmployees.length,\n active_in_period_count:\n trinidadTobagoEmployees.filter(\n (employee) =>\n employee.overlaps_period\n ).length,\n active_status_count:\n trinidadTobagoEmployees.filter(\n (employee) =>\n normalize(employee.status) ===\n 'active'\n ).length,\n fetch_complete: fetchComplete,\n validation_available:\n fetchComplete &&\n bambooValidationEmployees.length > 0,\n validation_candidates_count:\n bambooValidationEmployees.length,\n relevant_outside_country_count:\n eligibleRelevantEmployeesOutsideCountry.length,\n relevant_outside_country_total_count:\n relevantEmployeesOutsideCountry.length,\n relevant_outside_country_excluded_count:\n excludedRelevantEmployeesOutsideCountry.length,\n relevant_outside_country_excluded:\n excludedRelevantEmployeesOutsideCountry,\n validation_rule:\n 'TT country/location, or exact contextual name that is Active and overlaps the period',\n employees:\n bambooValidationEmployees,\n restricted_fields:\n restrictedFields,\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
464,
896
],
"id": "bceb89b4-08e9-41c6-b412-f996a61bbcd9",
"name": "Normalizar BambooHR TT"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
1344,
1376
],
"id": "63b76f71-f399-4e62-8eac-1c60d0ad24cb",
"name": "Merge - Agregar BambooHR TT"
},
{
"parameters": {
"jsCode": "const data = $input.first().json || {};\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction moneyDiff(a, b) {\n return roundMoney((Number(a) || 0) - (Number(b) || 0));\n}\n\nfunction moneyEquals(a, b, tolerance = 0.02) {\n return Math.abs(roundMoney(a) - roundMoney(b)) <= tolerance;\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction normalizeName(value) {\n return String(value ?? '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['`-]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction nameWords(value) {\n const ignored = new Set(['de', 'del', 'la', 'las', 'los', 'y', 'e', 'el']);\n return normalizeName(value)\n .split(' ')\n .filter((word) => word.length > 1 && !ignored.has(word));\n}\n\nfunction editDistance(a, b) {\n if (a === b) return 0;\n if (!a) return b.length;\n if (!b) return a.length;\n\n const previous = Array.from({ length: b.length + 1 }, (_, index) => index);\n\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n\n for (let j = 1; j <= b.length; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n\n current[j] = Math.min(\n current[j - 1] + 1,\n previous[j] + 1,\n previous[j - 1] + cost\n );\n }\n\n for (let j = 0; j < current.length; j++) {\n previous[j] = current[j];\n }\n }\n\n return previous[b.length];\n}\n\nfunction tokenMatches(a, b) {\n if (a === b) return true;\n\n const minLength = Math.min(a.length, b.length);\n\n if (minLength >= 8 && editDistance(a, b) <= 2) return true;\n if (minLength >= 5 && editDistance(a, b) <= 1) return true;\n\n return false;\n}\n\nfunction bambooTokenMatches(a, b) {\n if (tokenMatches(a, b)) return true;\n\n const minLength = Math.min(a.length, b.length);\n const maxLength = Math.max(a.length, b.length);\n const distance = editDistance(a, b);\n\n // Tolera variaciones pequeñas de escritura entre Banco/Nómina y BambooHR,\n // por ejemplo Anessa <-> Annesa, sin flexibilizar el cruce principal.\n if (minLength >= 6 && distance <= 2) {\n return true;\n }\n\n // Permite apellidos compuestos como BeharrySingh vs Singh,\n // pero evita aceptar coincidencias demasiado amplias.\n return (\n minLength >= 4 &&\n maxLength - minLength <= 10 &&\n (\n a.startsWith(b) ||\n b.startsWith(a) ||\n a.endsWith(b) ||\n b.endsWith(a)\n )\n );\n}\n\nfunction samePersonName(a, b) {\n const normalizedA = normalizeName(a);\n const normalizedB = normalizeName(b);\n\n if (!normalizedA || !normalizedB) return false;\n if (normalizedA === normalizedB) return true;\n\n const compactA = normalizedA.replace(/\\s+/g, '');\n const compactB = normalizedB.replace(/\\s+/g, '');\n\n if (compactA === compactB) return true;\n\n const wordsA = nameWords(a);\n const wordsB = nameWords(b);\n\n if (!wordsA.length || !wordsB.length) return false;\n\n const usedB = new Set();\n let matches = 0;\n\n for (const wordA of wordsA) {\n const matchIndex = wordsB.findIndex((wordB, index) => {\n return !usedB.has(index) && tokenMatches(wordA, wordB);\n });\n\n if (matchIndex >= 0) {\n usedB.add(matchIndex);\n matches += 1;\n }\n }\n\n const smallerLength = Math.min(wordsA.length, wordsB.length);\n const ratio = matches / smallerLength;\n\n if (smallerLength <= 2) {\n return matches === smallerLength && matches >= 2;\n }\n\n return matches >= 2 && ratio >= 0.6;\n}\n\nfunction accountDistance(a, b) {\n return editDistance(normalizeAccount(a), normalizeAccount(b));\n}\n\nfunction accountRelationship(payrollAccount, bankAccount) {\n const payroll = normalizeAccount(payrollAccount);\n const bank = normalizeAccount(bankAccount);\n\n if (!payroll || !bank) {\n return { matches: false, type: 'none' };\n }\n\n if (payroll === bank) {\n return { matches: true, type: 'exact' };\n }\n\n const bankHasPayrollSuffix =\n bank.endsWith(payroll) &&\n bank.length > payroll.length &&\n bank.length - payroll.length <= 6;\n\n const payrollHasBankSuffix =\n payroll.endsWith(bank) &&\n payroll.length > bank.length &&\n payroll.length - bank.length <= 6;\n\n if (bankHasPayrollSuffix || payrollHasBankSuffix) {\n return { matches: true, type: 'reference_prefix' };\n }\n\n return { matches: false, type: 'none' };\n}\n\nfunction formatMoney(value) {\n return Math.abs(roundMoney(value)).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n}\n\nfunction bankNames(bank) {\n return Array.from(new Set([\n ...(Array.isArray(bank.bank_name_files) ? bank.bank_name_files : []),\n ...(Array.isArray(bank.bank_account_holders) ? bank.bank_account_holders : []),\n bank.bank_name_file || '',\n bank.bank_account_holder || '',\n ].filter(Boolean)));\n}\n\nfunction bankMatchesName(bank, payrollName) {\n return bankNames(bank).some((name) => samePersonName(payrollName, name));\n}\n\nfunction bestBankDisplayName(bank) {\n return (\n bank.bank_name_file ||\n bank.bank_account_holder ||\n bankNames(bank)[0] ||\n ''\n );\n}\n\nfunction bambooAliases(employee) {\n return Array.from(new Set([\n ...(Array.isArray(employee.aliases) ? employee.aliases : []),\n employee.full_name || '',\n [employee.first_name, employee.middle_name, employee.last_name]\n .filter(Boolean)\n .join(' '),\n [employee.preferred_name, employee.last_name]\n .filter(Boolean)\n .join(' '),\n ].map((value) => String(value || '').trim()).filter(Boolean)));\n}\n\nfunction bambooEmployeeNumber(employee) {\n return normalizeAccount(\n employee.employee_number ||\n employee.employeeNumber ||\n ''\n );\n}\n\nfunction isTrinidadTobagoBambooEmployee(employee) {\n const country = normalizeName(\n employee.country || ''\n );\n const location = normalizeName(\n employee.location || ''\n );\n\n return (\n country === 'tt' ||\n country === 'tto' ||\n country.includes('trinidad') ||\n country.includes('tobago') ||\n location === 'tt' ||\n location === 'tto' ||\n location.includes('trinidad') ||\n location.includes('tobago')\n );\n}\n\nfunction isBambooValidationEligible(employee) {\n // La versión corregida del normalizador declara este campo.\n if (employee.validation_eligible === true) {\n return true;\n }\n\n if (employee.validation_eligible === false) {\n return false;\n }\n\n // Compatibilidad defensiva si este nodo recibe datos de una ejecución\n // anterior: los perfiles de TT siguen siendo válidos. Un perfil de otro\n // país solo puede utilizarse cuando está Active y vigente en el período.\n if (isTrinidadTobagoBambooEmployee(employee)) {\n return true;\n }\n\n return (\n employee.overlaps_period === true &&\n normalizeName(employee.status) === 'active'\n );\n}\n\nfunction nameSimilarityScore(a, b) {\n const normalizedA = normalizeName(a);\n const normalizedB = normalizeName(b);\n\n if (!normalizedA || !normalizedB) return 0;\n if (normalizedA === normalizedB) return 1;\n\n const compactA = normalizedA.replace(/\\s+/g, '');\n const compactB = normalizedB.replace(/\\s+/g, '');\n\n if (compactA === compactB) return 1;\n\n const wordsA = nameWords(normalizedA);\n const wordsB = nameWords(normalizedB);\n\n if (!wordsA.length || !wordsB.length) return 0;\n\n const usedB = new Set();\n let matches = 0;\n\n for (const wordA of wordsA) {\n const index = wordsB.findIndex(\n (wordB, wordIndex) =>\n !usedB.has(wordIndex) &&\n bambooTokenMatches(wordA, wordB)\n );\n\n if (index >= 0) {\n usedB.add(index);\n matches += 1;\n }\n }\n\n if (matches < 2) return 0;\n\n const ratioToShorter =\n matches / Math.min(wordsA.length, wordsB.length);\n const ratioToLonger =\n matches / Math.max(wordsA.length, wordsB.length);\n\n return (\n ratioToShorter * 0.7 +\n ratioToLonger * 0.3\n );\n}\n\nfunction bankRowKey(row) {\n return [\n row.source_file || '',\n row.row_number || '',\n ].join('|');\n}\n\nfunction bankRowNames(row) {\n const rowKey = bankRowKey(row);\n\n const linkedPayrollNames =\n typeof linkedPayrollNamesByBankRow !== 'undefined'\n ? linkedPayrollNamesByBankRow.get(rowKey) || []\n : [];\n\n return Array.from(new Set([\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ...linkedPayrollNames,\n ].map((value) => String(value || '').trim()).filter(Boolean)));\n}\n\nfunction bankRowEmployeeNumbers(row) {\n const rowKey = bankRowKey(row);\n\n const linkedNumbers =\n typeof linkedPayrollNumbersByBankRow !== 'undefined'\n ? linkedPayrollNumbersByBankRow.get(rowKey) || []\n : [];\n\n return Array.from(new Set(\n linkedNumbers\n .map(normalizeAccount)\n .filter((value) => value.length >= 6)\n ));\n}\n\nfunction bankRowReferenceText(row) {\n return [\n row.reference || '',\n row.concept || '',\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ...bankRowEmployeeNumbers(row),\n ].join(' ');\n}\n\nfunction isClearlyNonEmployeePayment(row) {\n const normalized = normalizeName([\n row.concept || '',\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ].join(' '));\n\n return [\n 'pension alimenticia',\n 'embargo judicial',\n 'retencion judicial',\n ].some((token) =>\n normalized.includes(normalizeName(token))\n );\n}\n\nfunction buildBambooSearchIndex(employees) {\n const records = [];\n const exactAliasMap = new Map();\n const tokenIndexSets = new Map();\n const employeeNumberMap = new Map();\n\n for (let index = 0; index < employees.length; index++) {\n const employee = employees[index];\n const aliases = bambooAliases(employee)\n .map((alias) => ({\n raw: alias,\n normalized: normalizeName(alias),\n }))\n .filter((alias) => alias.normalized);\n\n const uniqueAliases = [];\n const seenAliases = new Set();\n\n for (const alias of aliases) {\n if (seenAliases.has(alias.normalized)) continue;\n seenAliases.add(alias.normalized);\n uniqueAliases.push({\n ...alias,\n words: nameWords(alias.normalized),\n });\n\n const exact = exactAliasMap.get(alias.normalized) || [];\n exact.push(index);\n exactAliasMap.set(alias.normalized, exact);\n\n const uniqueTokens = Array.from(new Set(\n nameWords(alias.normalized)\n .filter((token) => token.length >= 3)\n ));\n\n for (const token of uniqueTokens) {\n const set = tokenIndexSets.get(token) || new Set();\n set.add(index);\n tokenIndexSets.set(token, set);\n }\n }\n\n const employeeNumber = bambooEmployeeNumber(employee);\n\n if (employeeNumber.length >= 6) {\n const matches = employeeNumberMap.get(employeeNumber) || [];\n matches.push(index);\n employeeNumberMap.set(employeeNumber, matches);\n }\n\n records.push({\n employee,\n aliases: uniqueAliases,\n employeeNumber,\n });\n }\n\n const tokenIndex = new Map();\n for (const [token, set] of tokenIndexSets.entries()) {\n tokenIndex.set(token, Array.from(set));\n }\n\n return {\n records,\n exactAliasMap,\n tokenIndex,\n employeeNumberMap,\n };\n}\n\nconst bambooMatchCache = new Map();\n\nfunction findBambooMatch(bankRow) {\n const names = bankRowNames(bankRow);\n const normalizedNames = Array.from(new Set(\n names.map(normalizeName).filter(Boolean)\n ));\n const directEmployeeNumbers = bankRowEmployeeNumbers(bankRow);\n const referenceNumberTokens = Array.from(new Set(\n (\n String(bankRowReferenceText(bankRow) || '')\n .match(/\\d{6,}/g) || []\n )\n .map(normalizeAccount)\n .filter((value) => value.length >= 6)\n ));\n\n const cacheKey = [\n ...directEmployeeNumbers.sort(),\n ...referenceNumberTokens.sort(),\n ...normalizedNames.sort(),\n ].join('|');\n\n if (bambooMatchCache.has(cacheKey)) {\n return bambooMatchCache.get(cacheKey);\n }\n\n const numberCandidateIndexes = new Set();\n\n for (const employeeNumber of directEmployeeNumbers) {\n for (\n const index of\n bambooSearch.employeeNumberMap.get(employeeNumber) || []\n ) {\n numberCandidateIndexes.add(index);\n }\n }\n\n if (!numberCandidateIndexes.size && referenceNumberTokens.length) {\n for (const referenceNumber of referenceNumberTokens) {\n for (\n const index of\n bambooSearch.employeeNumberMap.get(referenceNumber) || []\n ) {\n numberCandidateIndexes.add(index);\n }\n }\n }\n\n if (numberCandidateIndexes.size === 1) {\n const index = numberCandidateIndexes.values().next().value;\n const record = bambooSearch.records[index];\n\n // Un Employee Number enlazado desde la nómina es confiable.\n // Si proviene solamente de la referencia bancaria, también se exige\n // que el nombre corresponda para evitar falsos positivos por números\n // accidentales dentro del Addenda.\n const referenceNameScore = Math.max(\n 0,\n ...names.flatMap((currentBankName) =>\n record.aliases.map((alias) =>\n nameSimilarityScore(\n currentBankName,\n alias.normalized\n )\n )\n )\n );\n\n if (\n directEmployeeNumbers.length ||\n referenceNameScore >= 0.84\n ) {\n const result = {\n found: true,\n matched_by: directEmployeeNumbers.length\n ? 'employee_number_payroll'\n : 'employee_number_reference_and_name',\n confidence: directEmployeeNumbers.length\n ? 1\n : referenceNameScore,\n employee: record.employee,\n };\n bambooMatchCache.set(cacheKey, result);\n return result;\n }\n\n // La coincidencia numérica aislada se descarta y se continúa\n // con la validación por nombre.\n numberCandidateIndexes.clear();\n }\n\n const exactCandidateIndexes = new Set();\n\n for (const name of normalizedNames) {\n for (\n const index of\n bambooSearch.exactAliasMap.get(name) || []\n ) {\n exactCandidateIndexes.add(index);\n }\n }\n\n if (exactCandidateIndexes.size === 1) {\n const index = exactCandidateIndexes.values().next().value;\n const result = {\n found: true,\n matched_by: 'exact_name',\n confidence: 1,\n employee: bambooSearch.records[index].employee,\n bank_name: names[0] || '',\n bamboo_alias:\n bambooSearch.records[index].aliases[0]?.raw || '',\n };\n bambooMatchCache.set(cacheKey, result);\n return result;\n }\n\n // Confirmado manualmente en el directorio de BambooHR:\n // no debe aceptarse una coincidencia aproximada para este nombre.\n // Si en el futuro se crea el perfil con un alias exacto, el bloque\n // anterior lo reconocerá automáticamente antes de llegar aquí.\n const confirmedAbsentNames = new Set([\n 'isaac st bernard',\n ]);\n\n if (\n normalizedNames.some((name) =>\n confirmedAbsentNames.has(name)\n )\n ) {\n const result = {\n found: false,\n matched_by: null,\n confidence: 0,\n employee: null,\n ambiguous: false,\n best_candidate: null,\n reason: 'confirmed_absent_in_bamboohr',\n };\n bambooMatchCache.set(cacheKey, result);\n return result;\n }\n\n const candidateVotes = new Map();\n\n for (const name of normalizedNames) {\n const tokens = Array.from(new Set(\n nameWords(name)\n .filter((token) => token.length >= 3)\n ));\n\n for (const token of tokens) {\n const indexes = bambooSearch.tokenIndex.get(token) || [];\n\n // Evita que nombres demasiado comunes generen cientos de comparaciones.\n if (indexes.length > 180) continue;\n\n for (const index of indexes) {\n candidateVotes.set(\n index,\n (candidateVotes.get(index) || 0) + 1\n );\n }\n }\n }\n\n // Cuando una letra fue agregada, omitida o reemplazada, puede no existir\n // ningún token exacto compartido. En ese caso se buscan tokens cercanos\n // solamente entre palabras de longitud comparable.\n if (!candidateVotes.size) {\n for (const name of normalizedNames) {\n const queryTokens = Array.from(new Set(\n nameWords(name)\n .filter((token) => token.length >= 3)\n ));\n\n for (const queryToken of queryTokens) {\n for (\n const [indexedToken, indexes] of\n bambooSearch.tokenIndex.entries()\n ) {\n if (\n Math.abs(\n queryToken.length - indexedToken.length\n ) > 2\n ) {\n continue;\n }\n\n if (\n queryToken[0] !== indexedToken[0] &&\n queryToken.at(-1) !== indexedToken.at(-1)\n ) {\n continue;\n }\n\n if (\n !bambooTokenMatches(\n queryToken,\n indexedToken\n )\n ) {\n continue;\n }\n\n if (indexes.length > 180) continue;\n\n for (const index of indexes) {\n candidateVotes.set(\n index,\n (candidateVotes.get(index) || 0) + 0.75\n );\n }\n }\n }\n }\n }\n\n const candidateIndexes = Array.from(candidateVotes.entries())\n .sort((a, b) => b[1] - a[1])\n .slice(0, 180)\n .map(([index]) => index);\n\n let best = null;\n let second = null;\n\n for (const index of candidateIndexes) {\n const record = bambooSearch.records[index];\n let bestScoreForEmployee = 0;\n let bestBankName = '';\n let bestAlias = '';\n\n for (const currentBankName of names) {\n for (const alias of record.aliases) {\n const score = nameSimilarityScore(\n currentBankName,\n alias.normalized\n );\n\n if (score > bestScoreForEmployee) {\n bestScoreForEmployee = score;\n bestBankName = currentBankName;\n bestAlias = alias.raw;\n }\n }\n }\n\n if (bestScoreForEmployee <= 0) continue;\n\n const candidate = {\n employee: record.employee,\n score: bestScoreForEmployee,\n bank_name: bestBankName,\n bamboo_alias: bestAlias,\n };\n\n if (!best || candidate.score > best.score) {\n second = best;\n best = candidate;\n } else if (!second || candidate.score > second.score) {\n second = candidate;\n }\n }\n\n let result;\n\n if (\n best &&\n best.score >= 0.78 &&\n (!second || best.score - second.score >= 0.05)\n ) {\n result = {\n found: true,\n matched_by:\n normalizeName(best.bank_name) ===\n normalizeName(best.bamboo_alias)\n ? 'exact_name'\n : 'strong_name',\n confidence: best.score,\n employee: best.employee,\n bank_name: best.bank_name,\n bamboo_alias: best.bamboo_alias,\n };\n } else {\n result = {\n found: false,\n matched_by: null,\n confidence: best?.score || 0,\n employee: null,\n ambiguous: Boolean(\n best &&\n second &&\n best.score >= 0.7 &&\n best.score - second.score < 0.05\n ),\n best_candidate: best || null,\n };\n }\n\n bambooMatchCache.set(cacheKey, result);\n return result;\n}\n\nfunction supplementKey(supplement) {\n return [\n supplement.source_sheet || '',\n supplement.row_number || '',\n supplement.supplement_id || '',\n supplement.account || '',\n supplement.payroll_amount || 0,\n ].join('|');\n}\n\nconst payrollAccounts = (data.payroll?.grouped_by_account || [])\n .map((row) => ({\n ...row,\n group_key:\n row.group_key ||\n `${normalizeAccount(row.account)}:${row.currency || 'TTD'}`,\n account: normalizeAccount(row.account),\n employee_name: row.employee_name || row.employee || '',\n employee_number: row.employee_number || row.employeeNumber || '',\n currency: row.currency || 'TTD',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n source_rows: Array.isArray(row.source_rows) ? [...row.source_rows] : [],\n source_sheets: Array.isArray(row.source_sheets)\n ? [...row.source_sheets]\n : [],\n }))\n .filter((row) => row.account && row.payroll_amount > 0);\n\nconst payrollNoAccountRows = (data.payroll?.no_account_rows || [])\n .map((row) => ({\n ...row,\n account: '',\n employee_name: row.employee_name || row.employee || '',\n employee_number: row.employee_number || row.employeeNumber || '',\n currency: row.currency || 'TTD',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n }))\n .filter((row) => row.payroll_amount > 0);\n\nconst bankAccounts = (data.bank?.grouped_by_account || [])\n .map((row) => ({\n ...row,\n group_key:\n row.group_key ||\n `ACCOUNT:${normalizeAccount(row.account)}:${row.currency || 'TTD'}`,\n account: normalizeAccount(row.account),\n account_is_valid: Boolean(row.account_is_valid),\n currency: row.currency || 'TTD',\n amount: roundMoney(row.amount || row.bank_amount || row.bankAmount),\n source_rows: Array.isArray(row.source_rows) ? [...row.source_rows] : [],\n }))\n .filter((row) => row.amount > 0);\n\nconst rawBambooValidationEmployees =\n Array.isArray(data.bamboo?.employees)\n ? data.bamboo.employees\n : [];\n\nconst bambooEmployees =\n rawBambooValidationEmployees.filter(\n isBambooValidationEligible\n );\n\nconst excludedBambooValidationEmployees =\n rawBambooValidationEmployees\n .filter(\n (employee) =>\n !isBambooValidationEligible(employee)\n )\n .map((employee) => ({\n bamboo_id:\n employee.bamboo_id || '',\n employee_number:\n employee.employee_number || '',\n full_name:\n employee.full_name || '',\n country:\n employee.country || '',\n location:\n employee.location || '',\n status:\n employee.status || '',\n overlaps_period:\n Boolean(employee.overlaps_period),\n validation_scope:\n employee.validation_scope || '',\n }));\n\nconst bambooValidationAvailable =\n data.bamboo?.fetch_complete === true &&\n data.bamboo?.validation_available === true &&\n bambooEmployees.length > 0;\n\nconst bambooValidationWarning =\n bambooValidationAvailable\n ? null\n : (\n data.errors?.find((error) =>\n String(error || '').toLowerCase().includes('bamboohr')\n ) ||\n 'La validación Banco sin Bamboo no estuvo disponible porque la descarga de empleados de BambooHR quedó incompleta.'\n );\n\nconst bambooSearch = buildBambooSearchIndex(\n bambooEmployees\n);\n\nconst bankDetailRows = Array.isArray(data.bank?.rows)\n ? data.bank.rows\n : [];\n\nconst potentialSupplements = (\n data.payroll?.potential_supplements ||\n data.debug_payroll?.potential_supplements ||\n data.debug_payroll?.attached_supplements ||\n []\n)\n .map((row) => ({\n ...row,\n account: normalizeAccount(row.account),\n currency: row.currency || 'TTD',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n }))\n .filter((row) => {\n const id = normalizeName(row.supplement_id || '');\n\n return (\n row.account &&\n row.payroll_amount >= 10 &&\n !id.includes('back up')\n );\n });\n\nconst supplementsByAccountCurrency = new Map();\n\nfor (const supplement of potentialSupplements) {\n const key = `${supplement.account}:${supplement.currency}`;\n const current = supplementsByAccountCurrency.get(key) || [];\n\n current.push(supplement);\n supplementsByAccountCurrency.set(key, current);\n}\n\nfunction chooseConditionalSupplements(payroll, bank) {\n const baseAmount = roundMoney(payroll.payroll_amount);\n const bankAmount = roundMoney(bank.amount);\n const candidates =\n supplementsByAccountCurrency.get(\n `${payroll.account}:${payroll.currency}`\n ) || [];\n\n if (\n !candidates.length ||\n bankAmount <= baseAmount + 0.02\n ) {\n return {\n selected: [],\n effectiveAmount: baseAmount,\n baseAmount,\n improvement: 0,\n };\n }\n\n const baseDifference = Math.abs(baseAmount - bankAmount);\n let bestSelected = [];\n let bestAmount = baseAmount;\n let bestDifference = baseDifference;\n\n if (candidates.length <= 12) {\n const combinations = 1 << candidates.length;\n\n for (let mask = 1; mask < combinations; mask++) {\n const selected = [];\n let selectedTotal = 0;\n\n for (let index = 0; index < candidates.length; index++) {\n if ((mask & (1 << index)) !== 0) {\n selected.push(candidates[index]);\n selectedTotal = roundMoney(\n selectedTotal + candidates[index].payroll_amount\n );\n }\n }\n\n const candidateAmount = roundMoney(baseAmount + selectedTotal);\n const candidateDifference = Math.abs(\n candidateAmount - bankAmount\n );\n\n if (candidateDifference < bestDifference) {\n bestSelected = selected;\n bestAmount = candidateAmount;\n bestDifference = candidateDifference;\n }\n }\n } else {\n const sorted = [...candidates].sort(\n (a, b) => b.payroll_amount - a.payroll_amount\n );\n\n let runningAmount = baseAmount;\n const selected = [];\n\n for (const candidate of sorted) {\n const nextAmount = roundMoney(\n runningAmount + candidate.payroll_amount\n );\n\n if (\n Math.abs(nextAmount - bankAmount) <\n Math.abs(runningAmount - bankAmount)\n ) {\n selected.push(candidate);\n runningAmount = nextAmount;\n }\n }\n\n bestSelected = selected;\n bestAmount = runningAmount;\n bestDifference = Math.abs(bestAmount - bankAmount);\n }\n\n const improvement = roundMoney(\n baseDifference - bestDifference\n );\n\n // Evita sumar valores accidentales o inmateriales, como un \"Asignado\" de Q1.\n if (!bestSelected.length || improvement < 5) {\n return {\n selected: [],\n effectiveAmount: baseAmount,\n baseAmount,\n improvement: 0,\n };\n }\n\n return {\n selected: bestSelected,\n effectiveAmount: roundMoney(bestAmount),\n baseAmount,\n improvement,\n };\n}\n\nfunction getDirectCandidates(payroll, matchedBankKeys) {\n return bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n\n if (!relationship.matches) return false;\n\n // Un sufijo de referencia solamente es válido cuando el nombre también\n // corresponde a la misma persona.\n if (\n relationship.type === 'reference_prefix' &&\n !bankMatchesName(bank, payroll.employee_name)\n ) {\n return false;\n }\n\n return true;\n })\n .map((bank) => {\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n const supplementDecision =\n chooseConditionalSupplements(payroll, bank);\n\n return {\n bank,\n relationship,\n supplementDecision,\n nameMatches: bankMatchesName(bank, payroll.employee_name),\n };\n })\n .sort((a, b) => {\n const exactDifference =\n Number(b.relationship.type === 'exact') -\n Number(a.relationship.type === 'exact');\n\n if (exactDifference !== 0) return exactDifference;\n\n const nameDifference =\n Number(b.nameMatches) - Number(a.nameMatches);\n\n if (nameDifference !== 0) return nameDifference;\n\n return (\n Math.abs(\n a.supplementDecision.effectiveAmount - a.bank.amount\n ) -\n Math.abs(\n b.supplementDecision.effectiveAmount - b.bank.amount\n )\n );\n });\n}\n\nfunction buildSources(payroll, selectedSupplements) {\n const supplementRows = selectedSupplements.map((row) => ({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n amount: row.payroll_amount,\n supplement_original_name:\n row.supplement_original_name || row.employee_name || '',\n supplement_id: row.supplement_id || '',\n applied_conditionally: true,\n }));\n\n const sourceRows = [\n ...(payroll.source_rows || []),\n ...supplementRows,\n ];\n\n const sourceSheets = Array.from(new Set([\n ...(payroll.source_sheets || []),\n ...selectedSupplements\n .map((row) => row.source_sheet)\n .filter(Boolean),\n ]));\n\n return { sourceRows, sourceSheets };\n}\n\nconst matchedPayrollKeys = new Set();\nconst matchedBankKeys = new Set();\nconst matchedNoAccountIndexes = new Set();\nconst appliedSupplementKeys = new Set();\nconst appliedSupplements = [];\nconst finalExactReconciliations = [];\nconst rows = [];\n\nfunction registerSupplements(selected) {\n for (const supplement of selected || []) {\n const key = supplementKey(supplement);\n\n if (!appliedSupplementKeys.has(key)) {\n appliedSupplementKeys.add(key);\n appliedSupplements.push(supplement);\n }\n }\n}\n\n// 1) Cuenta exacta o referencia con prefijo, y monto conciliado.\nfor (const payroll of payrollAccounts) {\n const candidates = getDirectCandidates(\n payroll,\n matchedBankKeys\n ).filter((candidate) => {\n return moneyEquals(\n candidate.supplementDecision.effectiveAmount,\n candidate.bank.amount\n );\n });\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(payroll, decision.selected);\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `match_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory:\n candidate.relationship.type === 'reference_prefix'\n ? 'referencia_bancaria_con_prefijo'\n : decision.selected.length\n ? 'cuenta_monto_y_suplemento_condicional'\n : 'cuenta_y_monto_coinciden',\n observation:\n candidate.relationship.type === 'reference_prefix'\n ? 'Conciliado por nombre, monto y referencia bancaria con prefijo.'\n : decision.selected.length\n ? 'Conciliado correctamente. Se aplicó un suplemento porque el banco mostró un pago adicional.'\n : 'Conciliado correctamente.',\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 2) Cuenta diferente, pero nombre y monto coinciden.\n// Se ejecuta antes de crear diferencias directas para resolver casos como\n// Ashly/Ashley Ramos: la cuenta de la nómina apunta a otra transacción,\n// pero existe otra cuenta bancaria con el mismo nombre y monto correcto.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n if (!bankMatchesName(bank, payroll.employee_name)) return false;\n\n const decision = chooseConditionalSupplements(\n payroll,\n bank\n );\n\n return moneyEquals(\n decision.effectiveAmount,\n bank.amount\n );\n })\n .map((bank) => ({\n bank,\n supplementDecision: chooseConditionalSupplements(\n payroll,\n bank\n ),\n }));\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n\n // Las referencias con prefijo ya debieron resolverse en el paso 1.\n if (relationship.type === 'reference_prefix') continue;\n\n const sources = buildSources(payroll, decision.selected);\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `possible_wrong_account_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name || bestBankDisplayName(bank),\n employee_name:\n payroll.employee_name || bestBankDisplayName(bank),\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'nombre_y_monto_coinciden_cuenta_diferente',\n observation:\n `El nombre y el monto coinciden, pero la cuenta de nómina ` +\n `(${payroll.account || 'sin cuenta'}) es diferente a la cuenta ` +\n `del banco (${bank.account || 'sin cuenta válida'}).`,\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 3) Nómina sin cuenta válida: conciliar por nombre y monto.\nfor (\n let index = 0;\n index < payrollNoAccountRows.length;\n index++\n) {\n const payroll = payrollNoAccountRows[index];\n\n const candidates = bankAccounts.filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n if (!moneyEquals(bank.amount, payroll.payroll_amount)) {\n return false;\n }\n\n return bankMatchesName(bank, payroll.employee_name);\n });\n\n if (candidates.length !== 1) continue;\n\n const bank = candidates[0];\n\n matchedNoAccountIndexes.add(index);\n matchedBankKeys.add(bank.group_key);\n\n rows.push({\n id: `possible_missing_account_${index}_${bank.group_key}`,\n employee:\n payroll.employee_name || bestBankDisplayName(bank),\n employee_name:\n payroll.employee_name || bestBankDisplayName(bank),\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: bank.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'cuenta_faltante_en_nomina_nombre_y_monto_coinciden',\n observation:\n `El nombre y el monto coinciden, pero la nómina no tiene una cuenta bancaria válida registrada. El banco utilizó la cuenta ${bank.account}.`,\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n source_rows: [\n {\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n account: '',\n amount: payroll.payroll_amount,\n employee_name: payroll.employee_name,\n },\n ],\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 4) Diferencias reales en una cuenta exacta o equivalente.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = getDirectCandidates(\n payroll,\n matchedBankKeys\n );\n\n if (!candidates.length) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(payroll, decision.selected);\n const difference = moneyDiff(\n decision.effectiveAmount,\n bank.amount\n );\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `difference_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference,\n status: 'Riesgo',\n category: 'discrepancia',\n subcategory: 'diferencia_monto',\n observation:\n `Diferencia de ${payroll.currency} ` +\n `${formatMoney(difference)}.`,\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n\n// 4.5) Reconciliación final exacta de pares residuales.\n//\n// Este paso corrige casos en los que nómina y banco contienen:\n// - la misma cuenta normalizada;\n// - el mismo empleado;\n// - el mismo monto;\n// pero no fueron enlazados en los pasos anteriores por diferencias técnicas\n// de agrupación, moneda inferida o metadatos del CSV.\n//\n// Es deliberadamente conservador: exige una única contraparte bancaria.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n\n const payrollAccount = normalizeAccount(payroll.account);\n const bankAccount = normalizeAccount(bank.account);\n\n if (!payrollAccount || payrollAccount !== bankAccount) {\n return false;\n }\n\n if (!bankMatchesName(bank, payroll.employee_name)) {\n return false;\n }\n\n const decision = chooseConditionalSupplements(payroll, bank);\n\n return moneyEquals(\n decision.effectiveAmount,\n bank.amount\n );\n })\n .map((bank) => ({\n bank,\n supplementDecision: chooseConditionalSupplements(\n payroll,\n bank\n ),\n }));\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(\n payroll,\n decision.selected\n );\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n finalExactReconciliations.push({\n employee_name: payroll.employee_name,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payroll_currency: payroll.currency,\n bank_currency: bank.currency,\n payroll_amount: decision.effectiveAmount,\n bank_amount: bank.amount,\n payroll_group_key: payroll.group_key,\n bank_group_key: bank.group_key,\n });\n\n rows.push({\n id: `final_exact_match_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: bank.currency || payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory: 'reconciliacion_final_cuenta_nombre_monto',\n observation:\n 'Conciliado por cuenta, nombre y monto en la validación final.',\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 5) Nómina con cuenta sin pago bancario.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n rows.push({\n id: `payroll_without_bank_${payroll.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: '',\n bank_account: '',\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n payrollBaseAmount: payroll.payroll_amount,\n payroll_base_amount: payroll.payroll_amount,\n bankAmount: 0,\n bank_amount: 0,\n difference: payroll.payroll_amount,\n status: 'Riesgo',\n category: 'discrepancia',\n subcategory: 'nomina_con_cuenta_sin_pago_banco',\n observation:\n 'Está en nómina, pero no aparece pagado en el banco.',\n applied_supplements: [],\n source_sheets: payroll.source_sheets,\n source_rows: payroll.source_rows,\n });\n}\n\n// 6) Banco sin nómina.\nfor (const bank of bankAccounts) {\n if (matchedBankKeys.has(bank.group_key)) continue;\n\n rows.push({\n id: `bank_without_payroll_${bank.group_key}`,\n employee:\n bestBankDisplayName(bank) || 'Pago bancario sin nómina',\n employee_name:\n bestBankDisplayName(bank) || 'Pago bancario sin nómina',\n employeeNumber: '',\n employee_number: '',\n account: bank.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: bank.currency,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: roundMoney(0 - bank.amount),\n status: 'Pendiente revisión',\n category: 'banco_sin_nomina',\n subcategory: 'pago_banco_sin_fila_nomina',\n observation:\n 'Recibió un pago en el banco, pero no aparece en la nómina cargada.',\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 7) Nómina sin cuenta que no pudo conciliarse.\nfor (\n let index = 0;\n index < payrollNoAccountRows.length;\n index++\n) {\n if (matchedNoAccountIndexes.has(index)) continue;\n\n const payroll = payrollNoAccountRows[index];\n\n rows.push({\n id:\n `payroll_without_account_` +\n `${payroll.source_sheet}_${payroll.row_number}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: '',\n payrollAccount: '',\n payroll_account: '',\n bankAccount: '',\n bank_account: '',\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n bankAmount: 0,\n bank_amount: 0,\n difference: payroll.payroll_amount,\n status: 'Pendiente revisión',\n category: 'nomina_sin_cuenta',\n subcategory: 'nomina_sin_cuenta_bancaria',\n observation:\n 'Tiene monto en nómina, pero no tiene una cuenta bancaria válida para cruzar contra el banco.',\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n });\n}\n\n// 8) Consolidar el mismo empleado cuando aparece con dos cuentas de nómina.\nconst originalRows = [...rows];\nconst usedRowIds = new Set();\nconst consolidatedRows = [];\n\nfor (const differenceRow of originalRows) {\n if (\n differenceRow.category !== 'discrepancia' ||\n differenceRow.subcategory !== 'diferencia_monto' ||\n usedRowIds.has(differenceRow.id)\n ) {\n continue;\n }\n\n const extraPayrollRow = originalRows.find((candidate) => {\n if (\n candidate.id === differenceRow.id ||\n usedRowIds.has(candidate.id) ||\n candidate.subcategory !==\n 'nomina_con_cuenta_sin_pago_banco' ||\n candidate.currency !== differenceRow.currency\n ) {\n return false;\n }\n\n const samePerson = samePersonName(\n differenceRow.employee_name || differenceRow.employee,\n candidate.employee_name || candidate.employee\n );\n\n const similarAccounts =\n accountDistance(\n differenceRow.account,\n candidate.account\n ) <= 2;\n\n const combinedPayroll = roundMoney(\n differenceRow.payroll_amount +\n candidate.payroll_amount\n );\n\n const totalMatches = moneyEquals(\n combinedPayroll,\n differenceRow.bank_amount\n );\n\n return samePerson && similarAccounts && totalMatches;\n });\n\n if (!extraPayrollRow) continue;\n\n usedRowIds.add(differenceRow.id);\n usedRowIds.add(extraPayrollRow.id);\n\n const totalPayroll = roundMoney(\n differenceRow.payroll_amount +\n extraPayrollRow.payroll_amount\n );\n\n const accounts = Array.from(new Set([\n differenceRow.account,\n extraPayrollRow.account,\n ].filter(Boolean)));\n\n consolidatedRows.push({\n id:\n `split_account_` +\n `${differenceRow.account}_${extraPayrollRow.account}`,\n employee: differenceRow.employee_name,\n employee_name: differenceRow.employee_name,\n employeeNumber:\n differenceRow.employee_number ||\n extraPayrollRow.employee_number ||\n '',\n employee_number:\n differenceRow.employee_number ||\n extraPayrollRow.employee_number ||\n '',\n account:\n differenceRow.bank_account ||\n differenceRow.account,\n payrollAccount: accounts.join(' / '),\n payroll_account: accounts.join(' / '),\n bankAccount: differenceRow.bank_account,\n bank_account: differenceRow.bank_account,\n currency: differenceRow.currency,\n payrollAmount: totalPayroll,\n payroll_amount: totalPayroll,\n bankAmount: differenceRow.bank_amount,\n bank_amount: differenceRow.bank_amount,\n difference: moneyDiff(\n totalPayroll,\n differenceRow.bank_amount\n ),\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'mismo_empleado_con_cuentas_distintas_en_nomina',\n observation:\n `El total de nómina coincide con el banco, pero el empleado ` +\n `aparece con cuentas distintas en la nómina: ` +\n `${accounts.join(' y ')}. La cuenta utilizada por el banco ` +\n `fue ${differenceRow.bank_account}.`,\n applied_supplements:\n differenceRow.applied_supplements || [],\n source_sheets: Array.from(new Set([\n ...(differenceRow.source_sheets || []),\n ...(extraPayrollRow.source_sheets || []),\n ])),\n source_rows: [\n ...(differenceRow.source_rows || []),\n ...(extraPayrollRow.source_rows || []),\n ],\n bank_source_rows:\n differenceRow.bank_source_rows || [],\n });\n}\n\nconst coreRows = [\n ...originalRows.filter(\n (row) => !usedRowIds.has(row.id)\n ),\n ...consolidatedRows,\n];\n\nconst coreCoincidencias = coreRows.filter(\n (row) => row.category === 'coincidencia'\n).length;\n\nconst coreDiscrepancias = coreRows.filter(\n (row) => row.category === 'discrepancia'\n).length;\n\nconst coreBancoSinNomina = coreRows.filter(\n (row) => row.category === 'banco_sin_nomina'\n).length;\n\nconst coreNominaSinCuenta = coreRows.filter(\n (row) => row.category === 'nomina_sin_cuenta'\n).length;\n\nconst corePosiblesCuentas = coreRows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n).length;\n\nconst linkedPayrollNamesByBankRow = new Map();\nconst linkedPayrollNumbersByBankRow = new Map();\n\nfor (const reconciliationRow of coreRows) {\n const linkedName =\n reconciliationRow.employee_name ||\n reconciliationRow.employee ||\n '';\n const linkedEmployeeNumber = normalizeAccount(\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n ''\n );\n\n for (\n const bankSourceRow of\n reconciliationRow.bank_source_rows || []\n ) {\n const rowKey = bankRowKey(bankSourceRow);\n\n const names =\n linkedPayrollNamesByBankRow.get(rowKey) || [];\n const numbers =\n linkedPayrollNumbersByBankRow.get(rowKey) || [];\n\n if (linkedName) names.push(linkedName);\n if (linkedEmployeeNumber.length >= 6) {\n numbers.push(linkedEmployeeNumber);\n }\n\n linkedPayrollNamesByBankRow.set(\n rowKey,\n Array.from(new Set(names))\n );\n linkedPayrollNumbersByBankRow.set(\n rowKey,\n Array.from(new Set(numbers))\n );\n }\n}\n\nconst bambooMatchDetails = [];\nconst bambooExcludedPayments = [];\nconst bankWithoutBambooMap = new Map();\n\nif (bambooValidationAvailable) {\nfor (const bankRow of bankDetailRows) {\n if (isClearlyNonEmployeePayment(bankRow)) {\n bambooExcludedPayments.push({\n source_file: bankRow.source_file,\n row_number: bankRow.row_number,\n reason: 'pago_no_empleado_identificado',\n bank_name_file: bankRow.bank_name_file,\n bank_account_holder:\n bankRow.bank_account_holder,\n amount: bankRow.amount,\n currency: bankRow.currency,\n });\n continue;\n }\n\n const match = findBambooMatch(bankRow);\n\n if (match.found) {\n bambooMatchDetails.push({\n source_file: bankRow.source_file,\n row_number: bankRow.row_number,\n account: bankRow.account,\n amount: bankRow.amount,\n currency: bankRow.currency,\n bank_name_file: bankRow.bank_name_file,\n bank_account_holder:\n bankRow.bank_account_holder,\n matched_by: match.matched_by,\n confidence: roundMoney(match.confidence),\n bamboo_employee_number:\n match.employee?.employee_number || '',\n bamboo_employee_name:\n match.employee?.full_name || '',\n bamboo_status:\n match.employee?.status || '',\n bamboo_country:\n match.employee?.country || '',\n bamboo_location:\n match.employee?.location || '',\n bamboo_validation_scope:\n match.employee?.validation_scope || '',\n bamboo_overlaps_period:\n Boolean(match.employee?.overlaps_period),\n });\n continue;\n }\n\n const displayName =\n bankRow.bank_name_file ||\n bankRow.bank_account_holder ||\n 'Pago bancario sin empleado identificado';\n\n const groupingKey = [\n normalizeAccount(bankRow.account),\n normalizeName(displayName),\n bankRow.currency || 'TTD',\n ].join('|');\n\n const current =\n bankWithoutBambooMap.get(groupingKey) || {\n id: `bank_without_bamboo_${groupingKey}`,\n employee: displayName,\n employee_name: displayName,\n bank_name_file:\n bankRow.bank_name_file || '',\n bank_account_holder:\n bankRow.bank_account_holder || '',\n account: normalizeAccount(bankRow.account),\n bankAccount: normalizeAccount(bankRow.account),\n bank_account: normalizeAccount(bankRow.account),\n currency: bankRow.currency || 'TTD',\n bankAmount: 0,\n bank_amount: 0,\n shipment_numbers: new Set(),\n references: new Set(),\n source_files: new Set(),\n source_rows: [],\n status: 'Pendiente revisión',\n category: 'banco_sin_bamboo',\n subcategory:\n 'pago_bancario_sin_empleado_bamboohr_tt',\n observation:\n 'Se encontró un pago en el banco, pero no se encontró una coincidencia confiable con un empleado de Trinidad y Tobago en BambooHR.',\n best_bamboo_candidate:\n match.best_candidate\n ? {\n employee_number:\n match.best_candidate.employee\n ?.employee_number || '',\n employee_name:\n match.best_candidate.employee\n ?.full_name || '',\n score: roundMoney(\n match.best_candidate.score\n ),\n }\n : null,\n ambiguous_bamboo_match:\n Boolean(match.ambiguous),\n };\n\n current.bankAmount = roundMoney(\n current.bankAmount +\n Number(bankRow.amount || 0)\n );\n current.bank_amount = current.bankAmount;\n\n if (bankRow.shipment_number) {\n current.shipment_numbers.add(\n bankRow.shipment_number\n );\n }\n\n if (bankRow.reference) {\n current.references.add(bankRow.reference);\n }\n\n if (bankRow.source_file) {\n current.source_files.add(\n bankRow.source_file\n );\n }\n\n current.source_rows.push(bankRow);\n bankWithoutBambooMap.set(\n groupingKey,\n current\n );\n}\n}\n\nconst bankWithoutBamboo = Array.from(\n bankWithoutBambooMap.values()\n).map((row) => ({\n ...row,\n shipment_numbers: Array.from(\n row.shipment_numbers\n ),\n references: Array.from(row.references),\n source_files: Array.from(row.source_files),\n difference: roundMoney(\n 0 - row.bank_amount\n ),\n}));\n\nconst nameDifferenceMap = new Map();\n\nfor (const reconciliationRow of coreRows) {\n const payrollName = String(\n reconciliationRow.employee_name ||\n reconciliationRow.employee ||\n ''\n ).trim();\n\n if (!payrollName) continue;\n\n for (\n const bankSourceRow of\n reconciliationRow.bank_source_rows || []\n ) {\n const bankName = String(\n bankSourceRow.bank_name_file ||\n bankSourceRow.participant_name ||\n bankSourceRow.bank_account_holder ||\n ''\n ).trim();\n\n if (\n !bankName ||\n samePersonName(payrollName, bankName)\n ) {\n continue;\n }\n\n const account = normalizeAccount(\n bankSourceRow.account ||\n reconciliationRow.bank_account ||\n reconciliationRow.bankAccount ||\n reconciliationRow.account ||\n ''\n );\n\n const key = [\n normalizeName(payrollName),\n normalizeName(bankName),\n account,\n bankSourceRow.source_file || '',\n bankSourceRow.row_number || '',\n ].join('|');\n\n if (nameDifferenceMap.has(key)) {\n continue;\n }\n\n nameDifferenceMap.set(key, {\n id: `bank_name_difference_${key}`,\n employee: payrollName,\n employee_name: payrollName,\n payroll_name: payrollName,\n bank_name: bankName,\n employeeNumber:\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n '',\n employee_number:\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n '',\n account,\n payrollAccount:\n reconciliationRow.payroll_account ||\n reconciliationRow.payrollAccount ||\n '',\n payroll_account:\n reconciliationRow.payroll_account ||\n reconciliationRow.payrollAccount ||\n '',\n bankAccount: account,\n bank_account: account,\n currency:\n bankSourceRow.currency ||\n reconciliationRow.currency ||\n 'TTD',\n payrollAmount:\n reconciliationRow.payroll_amount ||\n reconciliationRow.payrollAmount ||\n 0,\n payroll_amount:\n reconciliationRow.payroll_amount ||\n reconciliationRow.payrollAmount ||\n 0,\n bankAmount:\n bankSourceRow.amount || 0,\n bank_amount:\n bankSourceRow.amount || 0,\n difference: 0,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n subcategory:\n 'nombre_nomina_vs_participante_banco',\n observation:\n `El nombre registrado en la nómina (${payrollName}) ` +\n `es diferente al nombre enviado al banco (${bankName}).`,\n bank_name_file: payrollName,\n bank_account_holder: bankName,\n source_file:\n bankSourceRow.source_file || '',\n financial_institution_id:\n bankSourceRow.financial_institution_id || '',\n reference:\n bankSourceRow.reference || '',\n row_number:\n bankSourceRow.row_number || '',\n });\n }\n}\n\nconst nameDifferenceRows = Array.from(\n nameDifferenceMap.values()\n);\n\nfunction priority(row) {\n const category = String(\n row.category || ''\n ).toLowerCase();\n\n if (category === 'posible_cuenta_mal_digitada') return 1;\n if (category === 'discrepancia') return 2;\n if (category === 'banco_sin_nomina') return 3;\n if (category === 'nomina_sin_cuenta') return 4;\n if (category === 'diferencia_nombre_banco') return 5;\n if (category === 'coincidencia') return 99;\n\n return 50;\n}\n\nconst rowsFinales = [\n ...coreRows,\n ...nameDifferenceRows,\n].sort((a, b) => {\n const priorityDifference =\n priority(a) - priority(b);\n\n if (priorityDifference !== 0) {\n return priorityDifference;\n }\n\n return String(\n a.employee_name || ''\n ).localeCompare(\n String(b.employee_name || ''),\n 'es'\n );\n});\n\nconst appliedSupplementsTotal = roundMoney(\n appliedSupplements.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n )\n);\n\nconst totalNominaBase = roundMoney(\n data.payroll?.total_amount || 0\n);\n\nconst totalNomina = roundMoney(\n totalNominaBase + appliedSupplementsTotal\n);\n\nconst totalBanco = roundMoney(\n data.bank?.total_amount || 0\n);\n\nconst diferenciasNombreBanco =\n nameDifferenceRows.length;\n\nconst pendientes =\n coreDiscrepancias +\n coreBancoSinNomina +\n coreNominaSinCuenta +\n corePosiblesCuentas +\n bankWithoutBamboo.length +\n diferenciasNombreBanco;\n\nconst unusedPotentialSupplements =\n potentialSupplements.filter((row) => {\n return !appliedSupplementKeys.has(\n supplementKey(row)\n );\n });\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'cruce_nomina_tt_banco',\n errors: [],\n metadata: data.metadata || {},\n summary: {\n coincidencias: coreCoincidencias,\n // La tarjeta de la app agrupa todos los casos de discrepancia/riesgo.\n // Se conserva el detalle puro en discrepanciasMontoPago.\n discrepancias:\n coreDiscrepancias + corePosiblesCuentas,\n discrepanciasMontoPago:\n coreDiscrepancias,\n bancoSinNomina: coreBancoSinNomina,\n bancoSinBamboo: bankWithoutBamboo.length,\n nominaSinCuenta: coreNominaSinCuenta,\n diferenciasNombreBanco,\n posiblesCuentasMalDigitadas:\n corePosiblesCuentas,\n totalResultados:\n coreCoincidencias +\n coreDiscrepancias +\n coreBancoSinNomina +\n coreNominaSinCuenta +\n corePosiblesCuentas +\n bankWithoutBamboo.length +\n diferenciasNombreBanco,\n pendientes,\n filasNominaValidas:\n data.payroll?.valid_rows_count || 0,\n filasNominaSinCuenta:\n data.payroll?.no_account_rows_count || 0,\n suplementosPotenciales:\n potentialSupplements.length,\n suplementosNominaAplicados:\n appliedSupplements.length,\n suplementosNominaNoAplicados:\n unusedPotentialSupplements.length,\n suplementosNominaAdjuntados:\n appliedSupplements.length,\n suplementosNominaNoAdjuntados:\n data.payroll?.unattached_supplements_count || 0,\n reconciliacionesExactasFinales:\n finalExactReconciliations.length,\n cuentasNominaAgrupadas:\n payrollAccounts.length,\n transaccionesBanco:\n data.bank?.rows_count || 0,\n cuentasBancoAgrupadas:\n bankAccounts.length,\n empleadosBambooTT:\n Number(\n data.bamboo?.trinidad_tobago_count ||\n bambooEmployees.length\n ),\n empleadosBambooEnPeriodo:\n Number(\n data.bamboo?.active_in_period_count || 0\n ),\n bambooPaginasDescargadas:\n Number(\n data.bamboo?.pages_fetched || 0\n ),\n bambooEmpleadosEsperados:\n Number(\n data.bamboo?.expected_total || 0\n ),\n bambooDescargaCompleta:\n Boolean(\n data.bamboo?.fetch_complete\n ),\n bambooValidacionDisponible:\n bambooValidationAvailable,\n totalNominaBase,\n totalSuplementosAplicados:\n appliedSupplementsTotal,\n totalNomina,\n totalBanco,\n diferenciaTotal:\n moneyDiff(totalNomina, totalBanco),\n },\n rows: rowsFinales,\n bankWithoutBamboo,\n nameDifferences: nameDifferenceRows,\n bambooSummary: data.bamboo || {},\n reportUrl: null,\n debug: {\n sheet_summaries:\n data.payroll?.sheet_summaries || [],\n potential_supplements:\n potentialSupplements,\n applied_supplements:\n appliedSupplements,\n final_exact_reconciliations:\n finalExactReconciliations,\n bamboo_search:\n {\n employees_received:\n rawBambooValidationEmployees.length,\n employees_indexed:\n bambooSearch.records.length,\n employees_excluded:\n excludedBambooValidationEmployees.length,\n excluded_employees:\n excludedBambooValidationEmployees,\n exact_aliases:\n bambooSearch.exactAliasMap.size,\n indexed_tokens:\n bambooSearch.tokenIndex.size,\n cache_entries:\n bambooMatchCache.size,\n },\n bamboo_matches:\n bambooMatchDetails,\n bamboo_excluded_payments:\n bambooExcludedPayments,\n bamboo_validation_available:\n bambooValidationAvailable,\n bamboo_validation_warning:\n bambooValidationWarning,\n banco_sin_bamboo:\n bankWithoutBamboo,\n unused_potential_supplements:\n unusedPotentialSupplements,\n unattached_supplements:\n data.debug_payroll?.unattached_supplements || [],\n payroll_preview:\n payrollAccounts.slice(0, 10),\n bank_preview:\n bankAccounts.slice(0, 10),\n payroll_no_account_preview:\n payrollNoAccountRows.slice(0, 10),\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1600,
1376
],
"id": "d510b344-a903-40d4-9f07-3ebf7c7a3301",
"name": "Cruzar Nómina vs Banco"
},
{
"parameters": {
"jsCode": "const data = $input.first().json || {};\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction firstValue(value) {\n if (Array.isArray(value)) {\n return value\n .map(normalizeText)\n .filter(Boolean)\n .join(' / ');\n }\n\n return normalizeText(value);\n}\n\nfunction formatPeriodEnd(value) {\n const raw = normalizeText(value);\n\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(raw)) {\n return raw;\n }\n\n const [year, month, day] = raw.split('-');\n\n const monthNames = {\n '01': 'ene',\n '02': 'feb',\n '03': 'mar',\n '04': 'abr',\n '05': 'may',\n '06': 'jun',\n '07': 'jul',\n '08': 'ago',\n '09': 'sep',\n '10': 'oct',\n '11': 'nov',\n '12': 'dic',\n };\n\n return `${day}-${monthNames[month] || month}-${year}`;\n}\n\nfunction mainReportSense(row, difference) {\n const subcategory = normalizeText(\n row.subcategory\n ).toLowerCase();\n\n const payrollAmount = Number(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = Number(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n if (\n subcategory ===\n 'nomina_con_cuenta_sin_pago_banco' ||\n (bankAmount === 0 && payrollAmount > 0)\n ) {\n return 'No aparece pagado en banco';\n }\n\n if (difference > 0) {\n return 'Se pagó de menos';\n }\n\n if (difference < 0) {\n return 'Se pagó de más';\n }\n\n return 'Revisar';\n}\n\nfunction accountValues(value) {\n const values = Array.isArray(value)\n ? value\n : String(value ?? '')\n .split(/\\s*(?:\\/|;|,|\\by\\b)\\s*/i);\n\n return values\n .map((item) =>\n String(item ?? '')\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim()\n )\n .filter(\n (account) =>\n account.length >= 6 &&\n !/^0+$/.test(account)\n );\n}\n\nfunction payrollAccountsForWrongAccount(row) {\n const candidates = [\n row.payroll_account,\n row.payrollAccount,\n ...(Array.isArray(row.source_rows)\n ? row.source_rows.flatMap(\n (sourceRow) => [\n sourceRow.account,\n sourceRow.payroll_account,\n sourceRow.payrollAccount,\n ]\n )\n : []),\n ];\n\n return Array.from(\n new Set(\n candidates.flatMap(accountValues)\n )\n );\n}\n\nfunction bankAccountForWrongAccount(row) {\n return firstValue(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n );\n}\n\nfunction moneyLabel(value) {\n return Math.abs(\n roundMoney(value)\n ).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n}\n\nfunction wrongAccountTotalStatus(row) {\n const payrollAmount = roundMoney(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n const difference = roundMoney(\n payrollAmount - bankAmount\n );\n\n if (Math.abs(difference) <= 0.02) {\n return (\n 'El total de nómina coincide con ' +\n 'el total pagado por el banco.'\n );\n }\n\n if (difference > 0) {\n return (\n 'El total de nómina supera el total ' +\n `del banco por TT$${moneyLabel(difference)}.`\n );\n }\n\n return (\n 'El total pagado por el banco supera ' +\n `el total de nómina por TT$${moneyLabel(difference)}.`\n );\n}\n\nfunction wrongAccountFinding(row) {\n const existing = normalizeText(\n row.observation || ''\n );\n\n if (existing) return existing;\n\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n return (\n 'El empleado presenta una posible ' +\n 'inconsistencia entre la cuenta registrada ' +\n `en nómina (${payrollAccounts.join(' y ') || 'sin cuenta identificada'}) ` +\n `y la cuenta utilizada por el banco (${bankAccount || 'sin cuenta identificada'}).`\n );\n}\n\nconst metadata = data.metadata || {};\nconst summary = data.summary || {};\n\nconst rows = Array.isArray(data.rows)\n ? data.rows\n : [];\n\nconst bankWithoutBamboo =\n Array.isArray(data.bankWithoutBamboo)\n ? data.bankWithoutBamboo\n : [];\n\nconst periodLabel =\n metadata.period_label ||\n `${metadata.year || ''}-${metadata.month || ''}-${metadata.period_type || ''}`;\n\nconst periodEndLabel = formatPeriodEnd(\n metadata.period_end || ''\n);\n\nconst spreadsheetTitle =\n `Cruce de Cuentas GLM TT - ${periodLabel}`;\n\nconst sheetIds = {\n nominaVsBanco: 201,\n bancoSinNomina: 202,\n bancoSinBamboo: 203,\n diferenciasNombreBanco: 204,\n cuentaMalDigitada: 205,\n resumen: 206,\n};\n\nconst cuentaMalDigitadaCases = rows.filter(\n (row) =>\n row.category ===\n 'posible_cuenta_mal_digitada'\n);\n\nconst hasCuentaMalDigitada =\n cuentaMalDigitadaCases.length > 0;\n\nconst sheetTitles = {\n nominaVsBanco:\n '01 Nómina vs Banco',\n bancoSinNomina:\n '02 Banco sin Nómina',\n bancoSinBamboo:\n '03 Banco sin Bamboo',\n diferenciasNombreBanco:\n '04 Diferencias de Nombre',\n cuentaMalDigitada:\n '05 Cuenta Mal Digitada',\n resumen: hasCuentaMalDigitada\n ? '06 Resumen'\n : '05 Resumen',\n};\n\nconst mainRows = rows\n .filter(\n (row) =>\n row.category === 'discrepancia'\n )\n .map((row, index) => {\n const payrollAmount = roundMoney(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n const difference = roundMoney(\n row.difference ??\n (payrollAmount - bankAmount)\n );\n\n return [\n index + 1,\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.payroll_account ||\n row.payrollAccount ||\n row.account ||\n ''\n ),\n payrollAmount,\n bankAmount,\n difference,\n mainReportSense(\n row,\n difference\n ),\n normalizeText(\n row.status || 'Riesgo'\n ).toUpperCase(),\n ];\n });\n\nconst bancoSinNominaRows = rows\n .filter(\n (row) =>\n row.category ===\n 'banco_sin_nomina'\n )\n .map((row, index) => [\n index + 1,\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n firstValue(\n row.source_files ||\n row.source_file ||\n ''\n ),\n normalizeText(\n row.status ||\n 'Pendiente revisión'\n ).toUpperCase(),\n normalizeText(\n row.observation || ''\n ),\n ]);\n\nconst bancoSinBambooRows =\n bankWithoutBamboo.map(\n (row, index) => [\n index + 1,\n normalizeText(\n row.bank_name_file ||\n row.employee_name ||\n row.employee ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n firstValue(\n row.source_files ||\n row.source_file ||\n ''\n ),\n 'PENDIENTE REVISIÓN',\n ]\n );\n\nconst diferenciasNombreRows = rows\n .filter(\n (row) =>\n row.category ===\n 'diferencia_nombre_banco'\n )\n .map((row, index) => [\n index + 1,\n normalizeText(\n row.payroll_name ||\n row.employee_name ||\n row.employee ||\n row.bank_name_file ||\n ''\n ),\n normalizeText(\n row.bank_name ||\n row.bank_account_holder ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n normalizeText(\n row.status ||\n 'Pendiente revisión'\n ).toUpperCase(),\n normalizeText(\n row.observation || ''\n ),\n ]);\n\nconst cuentaMalDigitadaRows = [];\n\ncuentaMalDigitadaCases.forEach(\n (row, index) => {\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n const fields = [\n [\n 'Empleado',\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n ],\n [\n 'Cuentas registradas en las hojas de nómina',\n payrollAccounts.join(' y ') ||\n 'No se identificó una cuenta válida en la nómina.',\n ],\n [\n 'Cuenta utilizada por el banco',\n bankAccount ||\n 'No se identificó una cuenta válida en el banco.',\n ],\n [\n 'Estado del total',\n wrongAccountTotalStatus(row),\n ],\n [\n 'Hallazgo',\n wrongAccountFinding(row),\n ],\n [\n 'Clasificación',\n 'Posible cuenta mal digitada — revisar y unificar la cuenta registrada en nómina.',\n ],\n ];\n\n fields.forEach(\n (field, fieldIndex) => {\n cuentaMalDigitadaRows.push([\n fieldIndex === 0\n ? index + 1\n : '',\n field[0],\n field[1],\n ]);\n }\n );\n }\n);\n\nconst resumenRows = [\n ['Período', periodLabel],\n [\n 'Coincidencias',\n Number(summary.coincidencias || 0),\n ],\n [\n 'Discrepancias de monto o pago',\n Number(\n summary.discrepanciasMontoPago ??\n summary.discrepancias ??\n 0\n ),\n ],\n [\n 'Banco sin nómina',\n Number(summary.bancoSinNomina || 0),\n ],\n [\n 'Banco sin Bamboo',\n Number(summary.bancoSinBamboo || 0),\n ],\n [\n 'Nómina sin cuenta no conciliada',\n Number(summary.nominaSinCuenta || 0),\n ],\n [\n 'Diferencias de nombre',\n Number(\n summary.diferenciasNombreBanco || 0\n ),\n ],\n [\n 'Posibles cuentas mal digitadas',\n Number(\n summary.posiblesCuentasMalDigitadas || 0\n ),\n ],\n [\n 'Pendientes del cruce principal',\n Number(summary.pendientes || 0),\n ],\n [\n 'Empleados BambooHR Trinidad y Tobago',\n Number(summary.empleadosBambooTT || 0),\n ],\n [\n 'Empleados BambooHR en el período',\n Number(\n summary.empleadosBambooEnPeriodo || 0\n ),\n ],\n [\n 'Filas válidas de nómina',\n Number(\n summary.filasNominaValidas || 0\n ),\n ],\n [\n 'Filas de nómina sin cuenta detectadas',\n Number(\n summary.filasNominaSinCuenta || 0\n ),\n ],\n [\n 'Transacciones bancarias',\n Number(\n summary.transaccionesBanco || 0\n ),\n ],\n [\n 'Total nómina',\n roundMoney(summary.totalNomina || 0),\n ],\n [\n 'Total banco',\n roundMoney(summary.totalBanco || 0),\n ],\n [\n 'Diferencia total',\n roundMoney(\n summary.diferenciaTotal || 0\n ),\n ],\n];\n\nfunction reportValues(\n title,\n subtitle,\n header,\n body\n) {\n return [\n [\n title,\n ...Array(\n Math.max(header.length - 1, 0)\n ).fill(''),\n ],\n [\n subtitle,\n ...Array(\n Math.max(header.length - 1, 0)\n ).fill(''),\n ],\n Array(header.length).fill(''),\n header,\n ...body,\n ];\n}\n\nconst nominaVsBancoValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Diferencias de Monto Nómina vs. Banco · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Empleado',\n 'Cuenta',\n 'Monto en Nómina (TT$)',\n 'Monto en Banco (TT$)',\n 'Diferencia (TT$)',\n 'Sentido',\n 'Estado',\n ],\n mainRows\n );\n\nconst bancoSinNominaValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Pagos bancarios sin registro en la nómina · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Archivo',\n 'Estado',\n 'Observación',\n ],\n bancoSinNominaRows\n );\n\nconst bancoSinBambooValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Pagos en banco sin empleado identificado en BambooHR · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Archivo',\n 'Estado',\n ],\n bancoSinBambooRows\n );\n\nconst diferenciasNombreValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Diferencias de nombre entre nómina y banco · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en nómina',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Estado',\n 'Observación',\n ],\n diferenciasNombreRows\n );\n\nconst cuentaMalDigitadaValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Cuenta Mal Digitada en Nómina · Trinidad y Tobago · ${periodEndLabel}`,\n ['#', 'Campo', 'Detalle'],\n cuentaMalDigitadaRows\n );\n\nconst resumenValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Resumen del cruce Nómina vs. Banco · Trinidad y Tobago · ${periodEndLabel}`,\n ['Indicador', 'Valor'],\n resumenRows\n );\n\nconst valueData = [\n {\n range:\n `'${sheetTitles.nominaVsBanco}'!A1:H`,\n values: nominaVsBancoValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinNomina}'!A1:G`,\n values: bancoSinNominaValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinBamboo}'!A1:F`,\n values: bancoSinBambooValues,\n },\n {\n range:\n `'${sheetTitles.diferenciasNombreBanco}'!A1:G`,\n values: diferenciasNombreValues,\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n range:\n `'${sheetTitles.cuentaMalDigitada}'!A1:C`,\n values:\n cuentaMalDigitadaValues,\n },\n ]\n : []),\n {\n range:\n `'${sheetTitles.resumen}'!A1:B`,\n values: resumenValues,\n },\n];\n\nconst brandColor = {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n};\n\nconst whiteColor = {\n red: 1,\n green: 1,\n blue: 1,\n};\n\nconst borderColor = {\n red: 0.82,\n green: 0.86,\n blue: 0.88,\n};\n\nfunction mergeRow(\n sheetId,\n rowIndex,\n columnCount\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex: columnCount,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction formatRange(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n userEnteredFormat\n) {\n const formatFields =\n Object.keys(userEnteredFormat || {});\n\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat,\n },\n fields:\n `userEnteredFormat(${formatFields.join(',')})`,\n },\n };\n}\n\nfunction titleFormat(\n sheetId,\n rowIndex,\n columnCount,\n options = {}\n) {\n return formatRange(\n sheetId,\n rowIndex,\n rowIndex + 1,\n 0,\n columnCount,\n {\n backgroundColor: brandColor,\n textFormat: {\n bold: options.bold ?? true,\n italic:\n options.italic ?? false,\n fontSize:\n options.fontSize ?? 12,\n foregroundColor:\n whiteColor,\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction headerFormat(\n sheetId,\n columnCount\n) {\n return formatRange(\n sheetId,\n 3,\n 4,\n 0,\n columnCount,\n {\n backgroundColor: brandColor,\n textFormat: {\n bold: true,\n foregroundColor:\n whiteColor,\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction freezeRows(\n sheetId,\n count\n) {\n return {\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount: count,\n },\n },\n fields:\n 'gridProperties.frozenRowCount',\n },\n };\n}\n\nfunction setFilter(\n sheetId,\n columnCount,\n endRowIndex\n) {\n return {\n setBasicFilter: {\n filter: {\n range: {\n sheetId,\n startRowIndex: 3,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex:\n columnCount,\n },\n },\n },\n };\n}\n\nfunction setColumnWidth(\n sheetId,\n index,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: index,\n endIndex: index + 1,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction setRowHeight(\n sheetId,\n startIndex,\n endIndex,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction borderFormat(\n sheetId,\n columnCount,\n endRowIndex\n) {\n const border = {\n style: 'SOLID',\n color: borderColor,\n };\n\n return [\n formatRange(\n sheetId,\n 3,\n endRowIndex,\n 0,\n columnCount,\n {\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n {\n updateBorders: {\n range: {\n sheetId,\n startRowIndex: 3,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex: columnCount,\n },\n top: border,\n bottom: border,\n left: border,\n right: border,\n innerHorizontal: border,\n innerVertical: border,\n },\n },\n ];\n}\n\nfunction moneyFormat(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n startRowIndex,\n endRowIndex\n) {\n return formatRange(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n {\n numberFormat: {\n type: 'NUMBER',\n pattern:\n '\"TT$\"#,##0.00',\n },\n horizontalAlignment:\n 'RIGHT',\n verticalAlignment:\n 'MIDDLE',\n }\n );\n}\n\nfunction statusFormat(\n sheetId,\n columnIndex,\n endRowIndex\n) {\n return formatRange(\n sheetId,\n 4,\n endRowIndex,\n columnIndex,\n columnIndex + 1,\n {\n backgroundColor: {\n red: 1,\n green: 0.92,\n blue: 0.92,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.82,\n green: 0.08,\n blue: 0.08,\n },\n },\n horizontalAlignment:\n 'CENTER',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction conditionalDifference(\n sheetId,\n endRowIndex,\n formula,\n backgroundColor,\n textColor\n) {\n return {\n addConditionalFormatRule: {\n rule: {\n ranges: [\n {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 5,\n endColumnIndex: 6,\n },\n ],\n booleanRule: {\n condition: {\n type: 'CUSTOM_FORMULA',\n values: [\n {\n userEnteredValue:\n formula,\n },\n ],\n },\n format: {\n backgroundColor,\n textFormat: {\n bold: true,\n foregroundColor:\n textColor,\n },\n },\n },\n },\n index: 0,\n },\n };\n}\n\nfunction styleReport(config) {\n const {\n sheetId,\n columnCount,\n bodyRowsCount,\n widths,\n moneyColumns = [],\n statusColumn = null,\n } = config;\n\n const endRowIndex = Math.max(\n 4 + bodyRowsCount,\n 4\n );\n\n const requests = [\n mergeRow(\n sheetId,\n 0,\n columnCount\n ),\n mergeRow(\n sheetId,\n 1,\n columnCount\n ),\n titleFormat(\n sheetId,\n 0,\n columnCount,\n {\n fontSize: 12,\n bold: true,\n }\n ),\n titleFormat(\n sheetId,\n 1,\n columnCount,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n }\n ),\n headerFormat(\n sheetId,\n columnCount\n ),\n freezeRows(sheetId, 4),\n setFilter(\n sheetId,\n columnCount,\n endRowIndex\n ),\n ...borderFormat(\n sheetId,\n columnCount,\n endRowIndex\n ),\n setRowHeight(\n sheetId,\n 0,\n 1,\n 30\n ),\n setRowHeight(\n sheetId,\n 1,\n 2,\n 28\n ),\n setRowHeight(\n sheetId,\n 3,\n 4,\n 42\n ),\n ...widths.map(\n (width, index) =>\n setColumnWidth(\n sheetId,\n index,\n width\n )\n ),\n ];\n\n if (bodyRowsCount > 0) {\n requests.push(\n setRowHeight(\n sheetId,\n 4,\n endRowIndex,\n 30\n )\n );\n\n for (\n const [startColumn, endColumn] of\n moneyColumns\n ) {\n requests.push(\n moneyFormat(\n sheetId,\n startColumn,\n endColumn,\n 4,\n endRowIndex\n )\n );\n }\n\n if (\n Number.isInteger(\n statusColumn\n )\n ) {\n requests.push(\n statusFormat(\n sheetId,\n statusColumn,\n endRowIndex\n )\n );\n }\n }\n\n return requests;\n}\n\nconst formatRequests = [\n ...styleReport({\n sheetId:\n sheetIds.nominaVsBanco,\n columnCount: 8,\n bodyRowsCount:\n mainRows.length,\n widths: [\n 48,\n 250,\n 145,\n 135,\n 135,\n 135,\n 180,\n 120,\n ],\n moneyColumns: [\n [3, 6],\n ],\n statusColumn: 7,\n }),\n\n ...(mainRows.length > 0\n ? [\n conditionalDifference(\n sheetIds.nominaVsBanco,\n 4 + mainRows.length,\n '=$F5>0',\n {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n {\n red: 0.45,\n green: 0.27,\n blue: 0,\n }\n ),\n conditionalDifference(\n sheetIds.nominaVsBanco,\n 4 + mainRows.length,\n '=$F5<0',\n {\n red: 1,\n green: 0.89,\n blue: 0.89,\n },\n {\n red: 0.85,\n green: 0.05,\n blue: 0.05,\n }\n ),\n ]\n : []),\n\n ...styleReport({\n sheetId:\n sheetIds.bancoSinNomina,\n columnCount: 7,\n bodyRowsCount:\n bancoSinNominaRows.length,\n widths: [\n 48,\n 230,\n 145,\n 135,\n 230,\n 140,\n 360,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.bancoSinBamboo,\n columnCount: 6,\n bodyRowsCount:\n bancoSinBambooRows.length,\n widths: [\n 48,\n 250,\n 145,\n 140,\n 250,\n 150,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.diferenciasNombreBanco,\n columnCount: 7,\n bodyRowsCount:\n diferenciasNombreRows.length,\n widths: [\n 48,\n 240,\n 240,\n 145,\n 140,\n 150,\n 420,\n ],\n moneyColumns: [[4, 5]],\n statusColumn: 5,\n }),\n];\n\nif (hasCuentaMalDigitada) {\n const endRowIndex =\n 4 +\n cuentaMalDigitadaRows.length;\n\n formatRequests.push(\n ...styleReport({\n sheetId:\n sheetIds.cuentaMalDigitada,\n columnCount: 3,\n bodyRowsCount:\n cuentaMalDigitadaRows.length,\n widths: [\n 48,\n 300,\n 520,\n ],\n statusColumn: null,\n })\n );\n\n cuentaMalDigitadaCases.forEach(\n (_, caseIndex) => {\n const startRowIndex =\n 4 + caseIndex * 6;\n\n const endCaseRowIndex =\n startRowIndex + 6;\n\n formatRequests.push(\n {\n mergeCells: {\n range: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex:\n endCaseRowIndex,\n startColumnIndex: 0,\n endColumnIndex: 1,\n },\n mergeType:\n 'MERGE_ALL',\n },\n },\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 0,\n 1,\n {\n backgroundColor: {\n red: 0.91,\n green: 0.95,\n blue: 0.99,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment:\n 'CENTER',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 1,\n 2,\n {\n backgroundColor: {\n red: 0.93,\n green: 0.97,\n blue: 0.90,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment:\n 'LEFT',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 2,\n 3,\n {\n horizontalAlignment:\n 'LEFT',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n setRowHeight(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 52\n )\n );\n }\n );\n}\n\nconst resumenEndRowIndex =\n 4 + resumenRows.length;\n\nformatRequests.push(\n ...styleReport({\n sheetId:\n sheetIds.resumen,\n columnCount: 2,\n bodyRowsCount:\n resumenRows.length,\n widths: [340, 180],\n statusColumn: null,\n }),\n moneyFormat(\n sheetIds.resumen,\n 1,\n 2,\n resumenEndRowIndex - 3,\n resumenEndRowIndex\n )\n);\n\nreturn [\n {\n json: {\n ok: true,\n stage:\n 'preparar_google_sheet_tt',\n metadata,\n summary,\n spreadsheetTitle,\n sheetIds,\n sheetTitles,\n createSpreadsheetBody: {\n properties: {\n title: spreadsheetTitle,\n },\n sheets: [\n {\n properties: {\n sheetId:\n sheetIds.nominaVsBanco,\n title:\n sheetTitles.nominaVsBanco,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.bancoSinNomina,\n title:\n sheetTitles.bancoSinNomina,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.bancoSinBamboo,\n title:\n sheetTitles.bancoSinBamboo,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.diferenciasNombreBanco,\n title:\n sheetTitles.diferenciasNombreBanco,\n },\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n properties: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n title:\n sheetTitles.cuentaMalDigitada,\n },\n },\n ]\n : []),\n {\n properties: {\n sheetId:\n sheetIds.resumen,\n title:\n sheetTitles.resumen,\n },\n },\n ],\n },\n valueBatchBody: {\n valueInputOption:\n 'RAW',\n data: valueData,\n },\n formatBatchBody: {\n requests: formatRequests,\n },\n originalResponse: data,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1872,
1376
],
"id": "46cce451-8fb6-4deb-bca3-b8cd8a7711ef",
"name": "Preparar Google Sheet"
},
{
"parameters": {
"method": "POST",
"url": "https://sheets.googleapis.com/v4/spreadsheets",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n(() => {\n const prepared =\n $('Preparar Google Sheet').first().json || {};\n\n const createBody =\n prepared.createSpreadsheetBody || {};\n\n if (\n !Array.isArray(createBody.sheets) ||\n createBody.sheets.length === 0\n ) {\n throw new Error(\n 'Preparar Google Sheet no devolvió las hojas que deben crearse.'\n );\n }\n\n return {\n properties: {\n ...(createBody.properties || {}),\n timeZone: 'America/Port_of_Spain',\n },\n\n sheets: createBody.sheets.map((sheet) => ({\n properties: {\n ...(sheet.properties || {}),\n\n gridProperties: {\n ...((sheet.properties || {}).gridProperties || {}),\n frozenRowCount: 1,\n },\n },\n })),\n };\n})()\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2128,
1376
],
"id": "79de4fa4-4d0f-45f6-af0f-6b1acb494cf0",
"name": "Crear Google Sheet",
"credentials": {
"httpBasicAuth": {
"id": "nIxZ7elcHvuzsRKW",
"name": "Neo4j"
},
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Crear Google Sheet').first().json.spreadsheetId + '/values:batchUpdate' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $('Preparar Google Sheet').first().json.valueBatchBody }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2384,
1376
],
"id": "6c5feb7b-b33a-4488-8231-e31622d67a30",
"name": "Escribir Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Crear Google Sheet').first().json.spreadsheetId + ':batchUpdate' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $('Preparar Google Sheet').first().json.formatBatchBody }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2640,
1376
],
"id": "41316f8a-f4fa-4b5f-852b-bdccfcfbdb7b",
"name": "Formatear Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"jsCode": "const createdSheet = $('Crear Google Sheet').first().json || {};\nconst spreadsheetId = createdSheet.spreadsheetId;\n\nif (!spreadsheetId) {\n throw new Error('No se recibió spreadsheetId desde Crear Google Sheet.');\n}\n\nconst allowedEmails = [\n 'iaracena@gomezleemarketing.com',\n 'ymadera@gomezleemarketing.com',\n 'mgomez@gomezleemarketing.com',\n 'jgomez@gomezleemarketing.com',\n];\n\nreturn allowedEmails.map((email) => ({\n json: {\n spreadsheetId,\n email,\n permissionBody: {\n type: 'user',\n role: 'writer',\n emailAddress: email,\n },\n },\n}));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2912,
1376
],
"id": "b7581ad4-9305-4757-bb96-5716f68fd6d3",
"name": "Preparar permisos Google Sheet"
},
{
"parameters": {
"method": "POST",
"url": "={{ 'https://www.googleapis.com/drive/v3/files/' + $json.spreadsheetId + '/permissions?sendNotificationEmail=false' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.permissionBody }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3168,
1376
],
"id": "a3ceb103-f683-48e5-8f71-c952c6d44626",
"name": "Compartir Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"jsCode": "const cruce =\n $('Cruzar Nómina vs Banco').first().json || {};\n\nconst createdSheet =\n $('Crear Google Sheet').first().json || {};\n\nconst metadata = cruce.metadata || {};\nconst summary = cruce.summary || {};\nconst debug = cruce.debug || {};\n\nconst spreadsheetId =\n createdSheet.spreadsheetId ||\n cruce.spreadsheetId ||\n '';\n\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n createdSheet.spreadsheet_url ||\n (\n spreadsheetId\n ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`\n : null\n );\n\nfunction toNumber(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed)\n ? parsed\n : 0;\n}\n\nfunction buildPeriodKey(periodMetadata) {\n const country =\n periodMetadata.country || 'TT';\n\n const year =\n periodMetadata.year || '';\n\n const month = String(\n periodMetadata.month || ''\n ).padStart(2, '0');\n\n const periodType =\n periodMetadata.period_type ||\n 'periodo';\n\n return (\n `${country}-${year}-${month}-${periodType}`\n );\n}\n\nconst discrepancias =\n toNumber(summary.discrepancias);\n\nconst discrepanciasMontoPago =\n toNumber(\n summary.discrepanciasMontoPago ??\n Math.max(\n 0,\n discrepancias -\n toNumber(\n summary.posiblesCuentasMalDigitadas\n )\n )\n );\n\nconst bancoSinNomina =\n toNumber(summary.bancoSinNomina);\n\nconst nominaSinCuenta =\n toNumber(summary.nominaSinCuenta);\n\nconst diferenciasNombreBanco =\n toNumber(\n summary.diferenciasNombreBanco\n );\n\nconst bancoSinBamboo =\n toNumber(summary.bancoSinBamboo);\n\nconst posiblesCuentasMalDigitadas =\n toNumber(\n summary.posiblesCuentasMalDigitadas\n );\n\nconst pendientes =\n toNumber(summary.pendientes) ||\n (\n discrepanciasMontoPago +\n bancoSinNomina +\n nominaSinCuenta +\n posiblesCuentasMalDigitadas +\n bancoSinBamboo +\n diferenciasNombreBanco\n );\n\nconst requiereRevision =\n pendientes > 0 ||\n bancoSinBamboo > 0;\n\nconst estado = requiereRevision\n ? 'pendiente_revision'\n : 'resuelto';\n\nconst payload = {\n source_app:\n metadata.source_app ||\n 'cruce-cuentas-glm-trinidad-tobago',\n\n country: 'TT',\n country_name:\n 'Trinidad y Tobago',\n\n year: toNumber(metadata.year),\n month: toNumber(metadata.month),\n period_type:\n metadata.period_type || '',\n period_label:\n metadata.period_label || '',\n period_start:\n metadata.period_start || null,\n period_end:\n metadata.period_end || null,\n period_key:\n buildPeriodKey({\n ...metadata,\n country: 'TT',\n }),\n\n payroll_file_name:\n metadata.payroll_file_name || '',\n\n bank_file_names:\n metadata.bank_file_names || [],\n\n coincidencias:\n toNumber(summary.coincidencias),\n\n discrepancias,\n\n banco_sin_bamboo:\n bancoSinBamboo,\n\n detalle_banco_sin_bamboo:\n Array.isArray(\n cruce.bankWithoutBamboo\n )\n ? cruce.bankWithoutBamboo\n : [],\n\n banco_sin_nomina:\n bancoSinNomina,\n\n nomina_sin_cuenta:\n nominaSinCuenta,\n\n nomina_sin_bamboo: 0,\n bamboo_sin_nomina: 0,\n\n filas_nomina_validas:\n toNumber(\n summary.filasNominaValidas\n ),\n\n cuentas_nomina_agrupadas:\n toNumber(\n summary.cuentasNominaAgrupadas\n ),\n\n transacciones_banco:\n toNumber(\n summary.transaccionesBanco\n ),\n\n cuentas_banco_agrupadas:\n toNumber(\n summary.cuentasBancoAgrupadas\n ),\n\n total_nomina:\n toNumber(summary.totalNomina),\n\n total_banco:\n toNumber(summary.totalBanco),\n\n diferencia_total:\n toNumber(\n summary.diferenciaTotal\n ),\n\n report_url: reportUrl,\n spreadsheet_id:\n spreadsheetId,\n estado,\n\n ejecutado_por_nombre:\n metadata.requested_by_name ||\n 'Usuario GLM',\n\n ejecutado_por_email:\n metadata.requested_by_email ||\n '',\n\n metadata: {\n ...metadata,\n country: 'TT',\n country_name:\n 'Trinidad y Tobago',\n diferencias_nombre_banco:\n diferenciasNombreBanco,\n banco_sin_bamboo:\n bancoSinBamboo,\n posibles_cuentas_mal_digitadas:\n toNumber(\n summary\n .posiblesCuentasMalDigitadas\n ),\n pendientes_cruce_principal:\n pendientes,\n requiere_revision:\n requiereRevision,\n },\n\n summary,\n\n debug: {\n sheet_summaries:\n debug.sheet_summaries || [],\n bank_name_differences:\n cruce.nameDifferences || [],\n bamboo_matches:\n debug.bamboo_matches || [],\n bamboo_excluded_payments:\n debug.bamboo_excluded_payments || [],\n banco_sin_bamboo:\n cruce.bankWithoutBamboo || [],\n },\n};\n\nreturn [\n {\n json: {\n ...cruce,\n\n // Se conserva la tabla histórica actual para\n // que la app pueda consultar todos los países\n // mediante el campo country y luego usar RPC.\n supabaseTable:\n 'cruces_cuentas_gt_reportes',\n\n supabasePayload: payload,\n reportUrl,\n spreadsheetId,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3424,
1376
],
"id": "2a352284-56df-4be7-bf28-e58d5aa59494",
"name": "Preparar histórico Supabase"
},
{
"parameters": {
"method": "POST",
"url": "https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Prefer",
"value": "return=representation"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.supabasePayload }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3680,
1376
],
"id": "ad36607d-a0d7-43e6-a261-aa1d5fd99b85",
"name": "Insertar histórico Supabase",
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "const prepared = $('Preparar Google Sheet').first().json || {};\nconst createdSheet = $('Crear Google Sheet').first().json || {};\n\nconst original =\n prepared.originalResponse ||\n prepared.original_response ||\n prepared.response ||\n {};\n\nconst spreadsheetId = createdSheet.spreadsheetId || '';\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n (spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : null);\n\nreturn [\n {\n json: {\n ok: original.ok ?? true,\n message: reportUrl\n ? 'Cruce procesado correctamente. Google Sheet generado.'\n : 'Cruce procesado correctamente, pero no se recibió URL del Google Sheet.',\n stage: reportUrl ? 'cruce_completado_con_reporte' : 'cruce_completado_sin_reporte',\n errors: original.errors || [],\n metadata: original.metadata || {},\n summary: original.summary || {},\n rows: original.rows || [],\n bankWithoutBamboo:\n original.bankWithoutBamboo || [],\n bambooSummary:\n original.bambooSummary || {},\n reportUrl,\n googleSheet: {\n spreadsheetId,\n spreadsheetUrl: reportUrl,\n },\n debug: {\n rows_returned: Array.isArray(original.rows) ? original.rows.length : 0,\n coincidencias: original.summary?.coincidencias ?? 0,\n discrepancias: original.summary?.discrepancias ?? 0,\n discrepanciasMontoPago:\n original.summary?.discrepanciasMontoPago ?? 0,\n posiblesCuentasMalDigitadas:\n original.summary?.posiblesCuentasMalDigitadas ?? 0,\n totalResultados:\n original.summary?.totalResultados ?? 0,\n bancoSinBamboo:\n original.summary?.bancoSinBamboo ?? 0,\n bancoSinBambooRows:\n Array.isArray(original.bankWithoutBamboo)\n ? original.bankWithoutBamboo.length\n : 0,\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3952,
1376
],
"id": "bc8c0691-dfe8-4980-9da9-022728ee77d1",
"name": "Preparar respuesta final"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{\n(() => {\n const data = $json || {};\n\n const original =\n data.originalResponse ||\n data.original_response ||\n data.response ||\n data.cruceResponse ||\n data.cruce_response ||\n data;\n\n const summary = original.summary || data.summary || {};\n const rows = original.rows || data.rows || [];\n const bankWithoutBamboo =\n original.bankWithoutBamboo ||\n data.bankWithoutBamboo ||\n [];\n const bambooSummary =\n original.bambooSummary ||\n data.bambooSummary ||\n {};\n\n const reportUrl =\n data.reportUrl ||\n data.report_url ||\n data.googleSheetUrl ||\n data.google_sheet_url ||\n data.spreadsheetUrl ||\n data.spreadsheet_url ||\n original.reportUrl ||\n original.report_url ||\n null;\n\n return {\n ok: original.ok ?? data.ok ?? true,\n message: reportUrl\n ? 'Cruce procesado correctamente. Google Sheet generado.'\n : 'Cruce procesado correctamente.',\n stage: reportUrl ? 'cruce_completado_con_reporte' : 'cruce_completado',\n errors: original.errors || data.errors || [],\n metadata: original.metadata || data.metadata || {},\n summary,\n rows,\n bankWithoutBamboo,\n bambooSummary,\n reportUrl,\n debug: {\n source_stage: data.stage || null,\n rows_returned:\n Array.isArray(rows) ? rows.length : 0,\n banco_sin_bamboo_rows:\n Array.isArray(bankWithoutBamboo)\n ? bankWithoutBamboo.length\n : 0,\n report_url_found: Boolean(reportUrl),\n },\n };\n})()\n}}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
4208,
1376
],
"id": "7aac07dc-6bb1-45ec-9784-0242de3a3a9d",
"name": "Respond to Webhook"
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Preparar entrada app",
"type": "main",
"index": 0
}
]
]
},
"Preparar entrada app": {
"main": [
[
{
"node": "Parsear CSV banco TT",
"type": "main",
"index": 0
},
{
"node": "Extract - BICE",
"type": "main",
"index": 0
},
{
"node": "Extract - Goldey Samuel",
"type": "main",
"index": 0
},
{
"node": "Extract - P&G",
"type": "main",
"index": 0
},
{
"node": "Extract - Whirlpool",
"type": "main",
"index": 0
},
{
"node": "Extract - KAD",
"type": "main",
"index": 0
},
{
"node": "Extract - GLM People",
"type": "main",
"index": 0
},
{
"node": "Extract - GLM",
"type": "main",
"index": 0
}
]
]
},
"Extract - BICE": {
"main": [
[
{
"node": "Merge Hojas TT 01-02",
"type": "main",
"index": 0
}
]
]
},
"Extract - Goldey Samuel": {
"main": [
[
{
"node": "Merge Hojas TT 01-02",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 01-02": {
"main": [
[
{
"node": "Merge Hojas TT 03",
"type": "main",
"index": 0
}
]
]
},
"Extract - P&G": {
"main": [
[
{
"node": "Merge Hojas TT 03",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 03": {
"main": [
[
{
"node": "Merge Hojas TT 04",
"type": "main",
"index": 0
}
]
]
},
"Extract - Whirlpool": {
"main": [
[
{
"node": "Merge Hojas TT 04",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 04": {
"main": [
[
{
"node": "Merge Hojas TT 05",
"type": "main",
"index": 0
}
]
]
},
"Extract - KAD": {
"main": [
[
{
"node": "Merge Hojas TT 05",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 05": {
"main": [
[
{
"node": "Merge Hojas TT 06",
"type": "main",
"index": 0
}
]
]
},
"Extract - GLM People": {
"main": [
[
{
"node": "Merge Hojas TT 06",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 06": {
"main": [
[
{
"node": "Merge Hojas TT 07",
"type": "main",
"index": 0
}
]
]
},
"Extract - GLM": {
"main": [
[
{
"node": "Merge Hojas TT 07",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 07": {
"main": [
[
{
"node": "Normalizar Nómina TT",
"type": "main",
"index": 0
}
]
]
},
"Parsear CSV banco TT": {
"main": [
[
{
"node": "Merge Banco + Nómina TT",
"type": "main",
"index": 0
}
]
]
},
"Normalizar Nómina TT": {
"main": [
[
{
"node": "Merge Banco + Nómina TT",
"type": "main",
"index": 1
}
]
]
},
"HTTP - Empleados BambooHR TT": {
"main": [
[
{
"node": "Normalizar BambooHR TT",
"type": "main",
"index": 0
}
]
]
},
"Merge Banco + Nómina TT": {
"main": [
[
{
"node": "HTTP - Empleados BambooHR TT",
"type": "main",
"index": 0
},
{
"node": "Merge - Agregar BambooHR TT",
"type": "main",
"index": 0
}
]
]
},
"Normalizar BambooHR TT": {
"main": [
[
{
"node": "Merge - Agregar BambooHR TT",
"type": "main",
"index": 1
}
]
]
},
"Merge - Agregar BambooHR TT": {
"main": [
[
{
"node": "Cruzar Nómina vs Banco",
"type": "main",
"index": 0
}
]
]
},
"Cruzar Nómina vs Banco": {
"main": [
[
{
"node": "Preparar Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Preparar Google Sheet": {
"main": [
[
{
"node": "Crear Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Crear Google Sheet": {
"main": [
[
{
"node": "Escribir Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Escribir Google Sheet": {
"main": [
[
{
"node": "Formatear Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Formatear Google Sheet": {
"main": [
[
{
"node": "Preparar permisos Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Preparar permisos Google Sheet": {
"main": [
[
{
"node": "Compartir Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Compartir Google Sheet": {
"main": [
[
{
"node": "Preparar histórico Supabase",
"type": "main",
"index": 0
}
]
]
},
"Preparar histórico Supabase": {
"main": [
[
{
"node": "Insertar histórico Supabase",
"type": "main",
"index": 0
}
]
]
},
"Insertar histórico Supabase": {
"main": [
[
{
"node": "Preparar respuesta final",
"type": "main",
"index": 0
}
]
]
},
"Preparar respuesta final": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": true
},
"staticData": null,
"meta": null,
"versionId": "8d7fee0e-1568-42f6-b897-795c78f921ca",
"activeVersionId": "8d7fee0e-1568-42f6-b897-795c78f921ca",
"versionCounter": 35,
"triggerCount": 1,
"shared": [
{
"updatedAt": "2026-07-14T14:51:32.557Z",
"createdAt": "2026-07-14T14:51:32.557Z",
"role": "workflow:owner",
"workflowId": "5AujMxduslftVg9z",
"projectId": "PJpTANzTXIFibWsW",
"project": {
"updatedAt": "2026-04-22T14:25:09.686Z",
"createdAt": "2026-04-22T14:22:54.790Z",
"id": "PJpTANzTXIFibWsW",
"name": "Isaac Aracena <iaracena@gomezleemarketing.com>",
"type": "personal",
"icon": null,
"description": null,
"creatorId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
}
],
"tags": [],
"activeVersion": {
"updatedAt": "2026-07-14T17:21:03.000Z",
"createdAt": "2026-07-14T17:21:00.395Z",
"versionId": "8d7fee0e-1568-42f6-b897-795c78f921ca",
"workflowId": "5AujMxduslftVg9z",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "nominatt-bamboo-test",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-512,
1424
],
"id": "670c388d-cd62-435c-a1fa-550e32dffc38",
"name": "Webhook",
"webhookId": "9c730860-7790-43a5-a3c0-bf5984ced244"
},
{
"parameters": {
"jsCode": "const item = $input.first();\n\nconst body = item.json.body || {};\nconst binary = item.binary || {};\n\nlet metadata = {};\n\ntry {\n metadata = typeof body.metadata === 'string'\n ? JSON.parse(body.metadata)\n : body.metadata || {};\n} catch (error) {\n metadata = {};\n}\n\nconst binaryKeys = Object.keys(binary);\n\nconst payrollKey = binaryKeys.find(\n (key) => key === 'payroll_file'\n);\n\nconst bankKeys = binaryKeys\n .filter((key) => key.startsWith('bank_files'))\n .sort();\n\nconst payrollFile = payrollKey\n ? {\n binary_key: payrollKey,\n file_name: binary[payrollKey].fileName,\n file_extension: binary[payrollKey].fileExtension,\n mime_type: binary[payrollKey].mimeType,\n file_size: binary[payrollKey].fileSize,\n }\n : null;\n\nconst bankFiles = bankKeys.map((key) => ({\n binary_key: key,\n file_name: binary[key].fileName,\n file_extension: binary[key].fileExtension,\n mime_type: binary[key].mimeType,\n file_size: binary[key].fileSize,\n}));\n\nconst receivedCountry = String(\n metadata.country || ''\n).trim().toUpperCase();\n\nconst errors = [];\n\nif (!['TT', 'TTO'].includes(receivedCountry)) {\n errors.push(\n 'El país recibido no es Trinidad y Tobago.'\n );\n}\n\nif (!metadata.year) {\n errors.push('No se recibió el año del cruce.');\n}\n\nif (!metadata.month) {\n errors.push('No se recibió el mes del cruce.');\n}\n\nif (!metadata.period_type) {\n errors.push('No se recibió el tipo de quincena.');\n}\n\nif (!metadata.period_start || !metadata.period_end) {\n errors.push('No se recibió el período calculado.');\n}\n\nif (!payrollFile) {\n errors.push('No se recibió el archivo de nómina.');\n}\n\nif (bankFiles.length === 0) {\n errors.push(\n 'No se recibió ningún archivo CSV del banco.'\n );\n}\n\nconst normalizedMetadata = {\n ...metadata,\n country: 'TT',\n country_name: 'Trinidad y Tobago',\n source_app:\n metadata.source_app ||\n 'cruce-cuentas-glm-trinidad-tobago',\n payroll_file_name:\n metadata.payroll_file_name ||\n payrollFile?.file_name ||\n '',\n bank_file_names:\n metadata.bank_file_names ||\n bankFiles.map((file) => file.file_name),\n};\n\nreturn [\n {\n json: {\n ok: errors.length === 0,\n stage: 'entrada_tt_recibida',\n errors,\n metadata: normalizedMetadata,\n payroll_file: payrollFile,\n bank_files: bankFiles,\n summary: {\n payroll_files_count:\n payrollFile ? 1 : 0,\n bank_files_count: bankFiles.length,\n },\n },\n binary,\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-272,
1424
],
"id": "91d42eab-5010-4db8-aa38-c71e5fb49b37",
"name": "Preparar entrada app"
},
{
"parameters": {
"jsCode": "const input = $input.first();\nconst json = input.json || {};\nconst binary = input.binary || {};\n\nfunction parseCsvLine(line) {\n const result = [];\n let current = '';\n let insideQuotes = false;\n\n for (let index = 0; index < line.length; index++) {\n const character = line[index];\n const nextCharacter = line[index + 1];\n\n if (\n character === '\"' &&\n insideQuotes &&\n nextCharacter === '\"'\n ) {\n current += '\"';\n index += 1;\n continue;\n }\n\n if (character === '\"') {\n insideQuotes = !insideQuotes;\n continue;\n }\n\n if (character === ',' && !insideQuotes) {\n result.push(current.trim());\n current = '';\n continue;\n }\n\n current += character;\n }\n\n result.push(current.trim());\n return result;\n}\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['`-]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction isValidAccount(value) {\n const account = normalizeAccount(value);\n return (\n account.length >= 6 &&\n !/^0+$/.test(account)\n );\n}\n\nfunction parseMoney(value) {\n const cleaned = String(value ?? '')\n .replace(/TTD/gi, '')\n .replace(/TT\\$/gi, '')\n .replace(/\\$/g, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction getColumnIndex(headers, names) {\n const normalizedHeaders =\n headers.map(normalizeForCompare);\n\n for (const name of names) {\n const expected = normalizeForCompare(name);\n const index = normalizedHeaders.findIndex(\n (header) => header === expected\n );\n\n if (index >= 0) return index;\n }\n\n return -1;\n}\n\nconst bankKeys = Object.keys(binary)\n .filter((key) => key.startsWith('bank_files'))\n .sort();\n\nconst allBankRows = [];\nconst fileSummaries = [];\n\nfor (const key of bankKeys) {\n const file = binary[key];\n const buffer =\n await this.helpers.getBinaryDataBuffer(0, key);\n\n let text = buffer.toString('utf8');\n\n if (text.includes('\\uFFFD')) {\n text = buffer.toString('latin1');\n }\n\n const lines = text\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter(Boolean);\n\n const headerIndex = lines.findIndex((line) => {\n const normalized = normalizeForCompare(line);\n\n return (\n normalized.includes('identifier') &&\n normalized.includes('account number') &&\n normalized.includes('amount') &&\n normalized.includes('participant name')\n );\n });\n\n if (headerIndex < 0) {\n fileSummaries.push({\n file_name: file.fileName,\n ok: false,\n rows_count: 0,\n total_amount: 0,\n error:\n 'No se encontró el encabezado esperado del archivo bancario de Trinidad y Tobago.',\n });\n continue;\n }\n\n const headers = parseCsvLine(\n lines[headerIndex]\n ).map(normalizeText);\n\n const indexIdentifier = getColumnIndex(\n headers,\n ['Identifier']\n );\n const indexAccount = getColumnIndex(\n headers,\n ['Account Number']\n );\n const indexAccountType = getColumnIndex(\n headers,\n ['Account type']\n );\n const indexAmount = getColumnIndex(\n headers,\n ['Amount']\n );\n const indexInstitution = getColumnIndex(\n headers,\n ['Financial Institution ID']\n );\n const indexParticipantId = getColumnIndex(\n headers,\n ['Participant ID']\n );\n const indexParticipantName = getColumnIndex(\n headers,\n ['Participant Name']\n );\n const indexTransactionType = getColumnIndex(\n headers,\n ['TR Type']\n );\n const indexAddenda = getColumnIndex(\n headers,\n ['Addenda']\n );\n\n const rowsFromFile = [];\n\n for (\n let lineIndex = headerIndex + 1;\n lineIndex < lines.length;\n lineIndex++\n ) {\n const values = parseCsvLine(lines[lineIndex]);\n\n const identifier = normalizeText(\n indexIdentifier >= 0\n ? values[indexIdentifier]\n : ''\n ).toUpperCase();\n\n // T = transacción. C = fila de control/totales.\n if (identifier !== 'T') continue;\n\n const account = normalizeAccount(\n indexAccount >= 0\n ? values[indexAccount]\n : ''\n );\n\n const amount = roundMoney(\n parseMoney(\n indexAmount >= 0\n ? values[indexAmount]\n : ''\n )\n );\n\n const participantName = normalizeText(\n indexParticipantName >= 0\n ? values[indexParticipantName]\n : ''\n );\n\n if (amount <= 0 || !participantName) {\n continue;\n }\n\n const accountIsValid =\n isValidAccount(account);\n\n const groupKey = accountIsValid\n ? `ACCOUNT:${account}:TTD`\n : `ROW:${file.fileName}:${lineIndex + 1}:TTD`;\n\n const row = {\n source_file: file.fileName,\n row_number: lineIndex + 1,\n group_key: groupKey,\n account,\n raw_account: account,\n account_is_valid: accountIsValid,\n bank_name_file: participantName,\n bank_account_holder: '',\n participant_name: participantName,\n participant_id: normalizeText(\n indexParticipantId >= 0\n ? values[indexParticipantId]\n : ''\n ),\n financial_institution_id:\n normalizeText(\n indexInstitution >= 0\n ? values[indexInstitution]\n : ''\n ),\n account_type: normalizeText(\n indexAccountType >= 0\n ? values[indexAccountType]\n : ''\n ),\n transaction_type: normalizeText(\n indexTransactionType >= 0\n ? values[indexTransactionType]\n : ''\n ),\n reference: normalizeText(\n indexAddenda >= 0\n ? values[indexAddenda]\n : ''\n ),\n addenda: normalizeText(\n indexAddenda >= 0\n ? values[indexAddenda]\n : ''\n ),\n shipment_number: '',\n plan_number: '',\n amount,\n currency: 'TTD',\n status: 'Procesado',\n };\n\n rowsFromFile.push(row);\n allBankRows.push(row);\n }\n\n fileSummaries.push({\n file_name: file.fileName,\n ok: true,\n rows_count: rowsFromFile.length,\n total_amount: roundMoney(\n rowsFromFile.reduce(\n (sum, row) => sum + row.amount,\n 0\n )\n ),\n error: null,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of allBankRows) {\n const current =\n groupedMap.get(row.group_key) || {\n group_key: row.group_key,\n account: row.account,\n raw_account: row.raw_account,\n account_is_valid: row.account_is_valid,\n amount: 0,\n currency: 'TTD',\n transactions_count: 0,\n bank_name_files: new Set(),\n bank_account_holders: new Set(),\n source_files: new Set(),\n institution_ids: new Set(),\n source_rows: [],\n };\n\n current.amount = roundMoney(\n current.amount + row.amount\n );\n current.transactions_count += 1;\n\n if (row.bank_name_file) {\n current.bank_name_files.add(\n row.bank_name_file\n );\n }\n\n if (row.source_file) {\n current.source_files.add(row.source_file);\n }\n\n if (row.financial_institution_id) {\n current.institution_ids.add(\n row.financial_institution_id\n );\n }\n\n current.source_rows.push(row);\n groupedMap.set(row.group_key, current);\n}\n\nconst groupedByAccount = Array.from(\n groupedMap.values()\n).map((row) => {\n const names = Array.from(\n row.bank_name_files\n );\n\n return {\n ...row,\n bank_name_file: names[0] || '',\n bank_account_holder: '',\n bank_name_files: names,\n bank_account_holders: [],\n source_files: Array.from(\n row.source_files\n ),\n institution_ids: Array.from(\n row.institution_ids\n ),\n };\n});\n\nconst totalAmount = roundMoney(\n allBankRows.reduce(\n (sum, row) => sum + row.amount,\n 0\n )\n);\n\nreturn [\n {\n json: {\n ...json,\n stage: 'banco_tt_parseado',\n bank: {\n source:\n 'csv_ach_trinidad_tobago',\n files_count: bankKeys.length,\n valid_files_count:\n fileSummaries.filter(\n (file) => file.ok\n ).length,\n rows_count: allBankRows.length,\n grouped_accounts_count:\n groupedByAccount.length,\n total_amount: totalAmount,\n totals_by_currency: {\n TTD: totalAmount,\n },\n name_differences_count: 0,\n name_differences: [],\n file_summaries: fileSummaries,\n rows: allBankRows,\n grouped_by_account:\n groupedByAccount,\n },\n },\n binary,\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
64,
1120
],
"id": "25638e1d-310a-492a-b1db-b8814bf14344",
"name": "Parsear CSV banco TT"
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "BICE"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
1520
],
"id": "d5f93467-cd0a-493b-973e-5abc9645ccd0",
"name": "Extract - BICE",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "Goldey Samuel"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
1696
],
"id": "94b7ba5c-45dd-4c34-93a9-1af811c81941",
"name": "Extract - Goldey Samuel",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "P&G"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
1856
],
"id": "6d91ab0f-1aab-4fd8-92ff-3f3dbeabed1d",
"name": "Extract - P&G",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "Whirlpool"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
2032
],
"id": "09716bc9-0431-433d-bd58-71a76a5bb31d",
"name": "Extract - Whirlpool",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "KAD"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
2208
],
"id": "27ab7829-5bfe-491d-9b8d-ce5dc90efb53",
"name": "Extract - KAD",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "GLM People"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
2368
],
"id": "5fcc84ba-9c13-4d98-815f-3e246f0f3fc0",
"name": "Extract - GLM People",
"retryOnFail": false
},
{
"parameters": {
"operation": "xlsx",
"binaryPropertyName": "payroll_file",
"options": {
"headerRow": true,
"sheetName": "GLM"
}
},
"type": "n8n-nodes-base.extractFromFile",
"typeVersion": 1.1,
"position": [
64,
2544
],
"id": "f5edbec8-7e3e-4ed2-9ca8-74374a302919",
"name": "Extract - GLM",
"retryOnFail": false
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
464,
1616
],
"id": "81c9547b-8ce5-4b83-acaf-87768239c463",
"name": "Merge Hojas TT 01-02"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
464,
1776
],
"id": "303f35ae-c7c3-4f79-8682-00ebbc23b6f9",
"name": "Merge Hojas TT 03"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
464,
1952
],
"id": "38ff1b61-1e0d-4896-b54c-44e47262f825",
"name": "Merge Hojas TT 04"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
464,
2112
],
"id": "2ccfeb6e-96a9-4107-b2ca-f4d2f34f2874",
"name": "Merge Hojas TT 05"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
464,
2288
],
"id": "058d4ed4-3ac8-4235-afdc-10a0e8355ef2",
"name": "Merge Hojas TT 06"
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
464,
2464
],
"id": "39dc6b0c-2cf2-4728-9d71-ae60b943973f",
"name": "Merge Hojas TT 07"
},
{
"parameters": {
"jsCode": "function normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeForCompare(value) {\n return normalizeText(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['`-]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalizeAccount(value) {\n if (\n value === null ||\n value === undefined ||\n value === ''\n ) {\n return '';\n }\n\n if (typeof value === 'number') {\n return String(Math.trunc(value));\n }\n\n return String(value)\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction parseMoney(value) {\n if (typeof value === 'number') {\n return Number.isFinite(value)\n ? value\n : 0;\n }\n\n const cleaned = String(value ?? '')\n .replace(/TTD/gi, '')\n .replace(/TT\\$/gi, '')\n .replace(/\\$/g, '')\n .replace(/,/g, '')\n .replace(/\\s+/g, '')\n .trim();\n\n const parsed = Number.parseFloat(cleaned);\n return Number.isFinite(parsed) ? parsed : 0;\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction getValue(row, possibleKeys) {\n for (const key of possibleKeys) {\n const value = row[key];\n\n if (\n value !== undefined &&\n value !== null &&\n value !== ''\n ) {\n return value;\n }\n }\n\n const rowKeys = Object.keys(row || {});\n\n for (const expected of possibleKeys) {\n const normalizedExpected =\n normalizeForCompare(expected);\n\n const matchingKey = rowKeys.find(\n (key) =>\n normalizeForCompare(key) ===\n normalizedExpected\n );\n\n if (!matchingKey) continue;\n\n const value = row[matchingKey];\n\n if (\n value !== undefined &&\n value !== null &&\n value !== ''\n ) {\n return value;\n }\n }\n\n return '';\n}\n\nfunction getNodeRows(nodeName) {\n try {\n return $items(nodeName)\n .map((item) => item.json || {})\n .filter((row) => {\n if (row.error) return false;\n\n const text = JSON.stringify(\n row || {}\n ).toLowerCase();\n\n return !(\n text.includes(\n 'spreadsheet does not contain sheet'\n ) ||\n text.includes('no sheet')\n );\n });\n } catch (error) {\n return [];\n }\n}\n\nfunction validEmployeeName(value) {\n const name = normalizeText(value);\n const normalized = normalizeForCompare(name);\n\n if (!name) return false;\n if (/^[\\d.,\\s]+$/.test(name)) return false;\n\n const invalid = [\n 'total',\n 'subtotal',\n 'gran total',\n 'total general',\n 'variable',\n 'empleado',\n 'first name',\n 'nombre',\n 'diferencia',\n 'total dias',\n ];\n\n return !invalid.some(\n (token) =>\n normalized === token ||\n normalized.startsWith(`${token} `)\n );\n}\n\nfunction validAccount(value) {\n const account = normalizeAccount(value);\n\n return (\n account.length >= 6 &&\n !/^0+$/.test(account)\n );\n}\n\nconst sheetConfigs = [\n {\n node: 'Extract - BICE',\n sheet: 'BICE',\n },\n {\n node: 'Extract - Goldey Samuel',\n sheet: 'Goldey Samuel',\n },\n {\n node: 'Extract - P&G',\n sheet: 'P&G',\n },\n {\n node: 'Extract - Whirlpool',\n sheet: 'Whirlpool',\n },\n {\n node: 'Extract - KAD',\n sheet: 'KAD',\n },\n {\n node: 'Extract - GLM People',\n sheet: 'GLM People',\n },\n {\n node: 'Extract - GLM',\n sheet: 'GLM',\n },\n];\n\nconst payrollRows = [];\nconst noAccountRows = [];\nconst ignoredRows = [];\nconst sheetSummaries = [];\n\nfor (const config of sheetConfigs) {\n const sourceRows = getNodeRows(\n config.node\n );\n\n let validRows = 0;\n let noAccountCount = 0;\n let ignoredCount = 0;\n let sheetTotal = 0;\n\n sourceRows.forEach((sourceRow, index) => {\n const period = normalizeText(\n getValue(sourceRow, ['Periodo'])\n );\n\n const employeeName = normalizeText(\n getValue(sourceRow, [\n 'First Name',\n 'Nombre completo',\n 'Empleado',\n 'Name',\n ])\n );\n\n const account = normalizeAccount(\n getValue(sourceRow, [\n 'Account #',\n 'Account Number',\n 'Cuenta bancaria',\n 'Cuenta Bancaria',\n ])\n );\n\n const email = normalizeText(\n getValue(sourceRow, [\n 'EMAIL',\n 'Email',\n 'Correo',\n ])\n ).toLowerCase();\n\n const amount = roundMoney(\n parseMoney(\n getValue(sourceRow, [\n 'NETO A PAGAR',\n 'Neto a Pagar',\n 'Net Pay',\n ])\n )\n );\n\n const client = normalizeText(\n getValue(sourceRow, ['Cuenta'])\n );\n\n const rowNumber = index + 2;\n\n const normalized = {\n source_sheet: config.sheet,\n row_number: rowNumber,\n period,\n employee_name: employeeName,\n employee_number: null,\n account,\n email,\n client,\n payroll_amount: amount,\n currency: 'TTD',\n };\n\n if (\n !period ||\n !validEmployeeName(employeeName) ||\n amount <= 0 ||\n amount > 500000\n ) {\n ignoredRows.push({\n ...normalized,\n reason:\n !period\n ? 'period_empty'\n : !validEmployeeName(employeeName)\n ? 'invalid_employee_name'\n : amount <= 0\n ? 'amount_zero_or_invalid'\n : 'suspicious_large_amount',\n });\n\n ignoredCount += 1;\n return;\n }\n\n sheetTotal = roundMoney(\n sheetTotal + amount\n );\n\n if (!validAccount(account)) {\n noAccountRows.push({\n ...normalized,\n account: '',\n });\n\n noAccountCount += 1;\n return;\n }\n\n payrollRows.push(normalized);\n validRows += 1;\n });\n\n sheetSummaries.push({\n sheet: config.sheet,\n node: config.node,\n raw_rows_count: sourceRows.length,\n valid_rows_count: validRows,\n no_account_rows_count:\n noAccountCount,\n ignored_rows_count: ignoredCount,\n total_amount: sheetTotal,\n });\n}\n\nconst groupedMap = new Map();\n\nfor (const row of payrollRows) {\n const groupKey =\n `${row.account}:${row.currency}`;\n\n const current =\n groupedMap.get(groupKey) || {\n group_key: groupKey,\n account: row.account,\n employee_name: row.employee_name,\n employee_number: null,\n email: row.email,\n currency: 'TTD',\n payroll_amount: 0,\n rows_count: 0,\n source_sheets: new Set(),\n source_rows: [],\n };\n\n current.payroll_amount = roundMoney(\n current.payroll_amount +\n row.payroll_amount\n );\n\n current.rows_count += 1;\n\n if (!current.email && row.email) {\n current.email = row.email;\n }\n\n current.source_sheets.add(\n row.source_sheet\n );\n\n current.source_rows.push({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n account: row.account,\n amount: row.payroll_amount,\n employee_name: row.employee_name,\n });\n\n groupedMap.set(groupKey, current);\n}\n\nconst groupedByAccount = Array.from(\n groupedMap.values()\n).map((row) => ({\n ...row,\n source_sheets: Array.from(\n row.source_sheets\n ),\n}));\n\nconst totalAmount = roundMoney(\n payrollRows.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n ) +\n noAccountRows.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n )\n);\n\nreturn [\n {\n json: {\n payroll: {\n source:\n 'template_trinidad_tobago',\n sheets_count:\n sheetConfigs.length,\n sheet_summaries:\n sheetSummaries,\n raw_rows_count:\n sheetSummaries.reduce(\n (sum, sheet) =>\n sum + sheet.raw_rows_count,\n 0\n ),\n valid_rows_count:\n payrollRows.length,\n no_account_rows_count:\n noAccountRows.length,\n ignored_rows_count:\n ignoredRows.length,\n grouped_accounts_count:\n groupedByAccount.length,\n attached_supplements_count: 0,\n potential_supplements_count: 0,\n potential_supplements: [],\n unattached_supplements_count: 0,\n total_amount: totalAmount,\n totals_by_currency: {\n TTD: totalAmount,\n },\n rows: payrollRows,\n no_account_rows:\n noAccountRows,\n grouped_by_account:\n groupedByAccount,\n },\n debug_payroll: {\n attached_supplements: [],\n potential_supplements: [],\n unattached_supplements: [],\n ignored_rows_preview:\n ignoredRows.slice(0, 100),\n no_account_rows_preview:\n noAccountRows.slice(0, 50),\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
832,
2032
],
"id": "564cf732-be79-434c-b876-051fb7bcd698",
"name": "Normalizar Nómina TT"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
1088,
1376
],
"id": "6152c18c-460c-49e0-b513-bf95b2b30865",
"name": "Merge Banco + Nómina TT"
},
{
"parameters": {
"url": "https://glm.bamboohr.com/api/v1/employees",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "fields",
"value": "employeeNumber,firstName,middleName,lastName,preferredName,displayName,fullName1,fullName2,fullName3,fullName4,fullName5,status,employmentStatus,employmentHistoryStatus,hireDate,originalHireDate,terminationDate,location,country,includeInPayroll,workEmail,homeEmail,bestEmail"
},
{
"name": "page[limit]",
"value": "2500"
}
]
},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Accept",
"value": "application/json"
}
]
},
"options": {
"pagination": {
"pagination": {
"paginationMode": "responseContainsNextURL",
"nextURL": "={{ $response.body._links?.next?.href || '' }}",
"paginationCompleteWhen": "other",
"completeExpression": "={{ !$response.body._links?.next?.href }}",
"limitPagesFetched": true,
"maxRequests": 10
}
},
"timeout": 120000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
64,
896
],
"id": "83d1cefc-0284-4ea6-9d82-bc2224ed5559",
"name": "HTTP - Empleados BambooHR TT",
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000,
"credentials": {
"httpBasicAuth": {
"id": "7VrpNZ2jBLmiJ35q",
"name": "BambooHR GLM Full Access"
}
}
},
{
"parameters": {
"jsCode": "const inputItems = $input.all();\nconst base = $('Preparar entrada app').first().json || {};\nconst reconciliationData =\n $('Merge Banco + Nómina TT').first().json || {};\nconst metadata = base.metadata || {};\n\nfunction clean(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction normalize(value) {\n return clean(value)\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction unique(values) {\n return Array.from(\n new Set(\n values\n .map(clean)\n .filter(Boolean)\n )\n );\n}\n\nfunction parseDate(value) {\n const raw = clean(value);\n if (!raw) return null;\n\n const direct = raw.match(/^(\\d{4})-(\\d{2})-(\\d{2})/);\n if (direct) {\n return `${direct[1]}-${direct[2]}-${direct[3]}`;\n }\n\n const date = new Date(raw);\n if (Number.isNaN(date.getTime())) return null;\n\n return date.toISOString().slice(0, 10);\n}\n\nfunction parseBoolean(value) {\n if (typeof value === 'boolean') return value;\n\n const normalized = normalize(value);\n\n return [\n 'true',\n 'yes',\n 'si',\n 'sí',\n '1',\n 'y',\n ].includes(normalized);\n}\n\nfunction isTrinidadTobago(employee) {\n const country = normalize(employee.country);\n const location = normalize(\n employee.location ||\n employee.jobInformationLocation ||\n employee.jobLocation\n );\n\n return (\n country === 'tt' ||\n country === 'tto' ||\n country.includes('trinidad') ||\n country.includes('tobago') ||\n location === 'tt' ||\n location === 'tto' ||\n location.includes('trinidad') ||\n location.includes('tobago')\n );\n}\n\nfunction overlapsPeriod(\n hireDate,\n terminationDate,\n periodStart,\n periodEnd\n) {\n if (!periodStart || !periodEnd) return false;\n\n const hiredBeforeEnd =\n !hireDate || hireDate <= periodEnd;\n\n const notTerminatedBeforeStart =\n !terminationDate ||\n terminationDate >= periodStart;\n\n return hiredBeforeEnd && notTerminatedBeforeStart;\n}\n\nfunction collectPageObjects(value, pages) {\n if (!value) return;\n\n if (Array.isArray(value)) {\n for (const entry of value) {\n collectPageObjects(entry, pages);\n }\n return;\n }\n\n if (typeof value !== 'object') return;\n\n if (value.body && typeof value.body === 'object') {\n collectPageObjects(value.body, pages);\n return;\n }\n\n if (\n Array.isArray(value.data) ||\n Array.isArray(value.employees)\n ) {\n pages.push(value);\n return;\n }\n\n if (value.json && typeof value.json === 'object') {\n collectPageObjects(value.json, pages);\n }\n}\n\nconst pageObjects = [];\n\nfor (const item of inputItems) {\n collectPageObjects(item.json, pageObjects);\n}\n\nconst employeeMap = new Map();\nlet expectedTotal = 0;\nlet restrictedFields = 0;\n\nfor (const page of pageObjects) {\n const pageEmployees =\n Array.isArray(page.data)\n ? page.data\n : Array.isArray(page.employees)\n ? page.employees\n : [];\n\n const pageTotal = Number(\n page.meta?.total ||\n page.total ||\n 0\n );\n\n if (Number.isFinite(pageTotal)) {\n expectedTotal = Math.max(\n expectedTotal,\n pageTotal\n );\n }\n\n for (const employee of pageEmployees) {\n const key =\n clean(employee.employeeId || employee.id) ||\n clean(employee.employeeNumber) ||\n clean(employee.bestEmail).toLowerCase() ||\n [\n clean(employee.firstName),\n clean(employee.middleName),\n clean(employee.lastName),\n ].filter(Boolean).join('|').toLowerCase();\n\n if (!key) continue;\n\n employeeMap.set(key, employee);\n\n restrictedFields += Array.isArray(\n employee._restrictedFields\n )\n ? employee._restrictedFields.length\n : 0;\n }\n}\n\nconst rawEmployees = Array.from(\n employeeMap.values()\n);\n\n// Algunos perfiles pueden existir en BambooHR, pero tener vacío o\n// incorrecto el campo country/location. Para que no aparezcan como\n// \"Banco sin Bamboo\", se incorporan como candidatos únicamente cuando\n// su nombre coincide exactamente con un nombre del banco o la nómina\n// de esta misma ejecución.\nconst relevantNames = new Set();\n\nfunction addRelevantName(value) {\n const normalized = normalize(value);\n if (normalized) relevantNames.add(normalized);\n}\n\nfor (const row of reconciliationData.bank?.rows || []) {\n addRelevantName(\n row.bank_name_file ||\n row.participant_name ||\n row.bank_account_holder ||\n ''\n );\n}\n\nfor (\n const row of\n reconciliationData.bank?.grouped_by_account || []\n) {\n for (const name of row.bank_name_files || []) {\n addRelevantName(name);\n }\n for (const name of row.bank_account_holders || []) {\n addRelevantName(name);\n }\n addRelevantName(row.bank_name_file || '');\n addRelevantName(row.bank_account_holder || '');\n}\n\nfor (const row of reconciliationData.payroll?.rows || []) {\n addRelevantName(\n row.employee_name ||\n row.employee ||\n ''\n );\n}\n\nfor (\n const row of\n reconciliationData.payroll?.no_account_rows || []\n) {\n addRelevantName(\n row.employee_name ||\n row.employee ||\n ''\n );\n}\n\nconst periodStart = clean(metadata.period_start);\nconst periodEnd = clean(metadata.period_end);\n\nconst allNormalized = rawEmployees.map((employee) => {\n const firstName = clean(employee.firstName);\n const middleName = clean(employee.middleName);\n const lastName = clean(employee.lastName);\n const preferredName = clean(\n employee.preferredName\n );\n\n const constructedFullName = [\n firstName,\n middleName,\n lastName,\n ].filter(Boolean).join(' ');\n\n const aliases = unique([\n employee.displayName,\n employee.fullName1,\n employee.fullName2,\n employee.fullName3,\n employee.fullName4,\n employee.fullName5,\n constructedFullName,\n [preferredName, lastName]\n .filter(Boolean)\n .join(' '),\n [firstName, lastName]\n .filter(Boolean)\n .join(' '),\n ]);\n\n const hireDate = parseDate(\n employee.hireDate ||\n employee.originalHireDate\n );\n\n const terminationDate = parseDate(\n employee.terminationDate\n );\n\n const status = clean(\n employee.status ||\n employee.employmentStatus ||\n employee.employmentHistoryStatus\n );\n\n const employeeNumber = clean(\n employee.employeeNumber ||\n employee.employee_number\n );\n\n return {\n bamboo_id: clean(\n employee.employeeId ||\n employee.id\n ),\n employee_number: employeeNumber,\n first_name: firstName,\n middle_name: middleName,\n last_name: lastName,\n preferred_name: preferredName,\n full_name:\n clean(employee.displayName) ||\n clean(employee.fullName1) ||\n constructedFullName,\n aliases,\n normalized_aliases:\n aliases.map(normalize).filter(Boolean),\n status,\n hire_date: hireDate,\n termination_date: terminationDate,\n location: clean(\n employee.location ||\n employee.jobInformationLocation ||\n employee.jobLocation\n ),\n country: clean(employee.country),\n include_in_payroll:\n parseBoolean(employee.includeInPayroll),\n work_email:\n clean(employee.workEmail).toLowerCase(),\n home_email:\n clean(employee.homeEmail).toLowerCase(),\n best_email: clean(\n employee.bestEmail ||\n employee.workEmail ||\n employee.homeEmail\n ).toLowerCase(),\n exists_in_bamboo: true,\n overlaps_period: overlapsPeriod(\n hireDate,\n terminationDate,\n periodStart,\n periodEnd\n ),\n };\n});\n\n// El universo principal para “Banco sin Bamboo” son los perfiles\n// pertenecientes a Trinidad y Tobago. Se permite rescatar un perfil con\n// país/location incorrecto únicamente cuando:\n// 1) su nombre coincide exactamente con un nombre del banco o nómina,\n// 2) está Active, y\n// 3) se encontraba vigente dentro del período procesado.\n// Esto conserva perfiles activos con metadata de país incorrecta, pero evita\n// que un registro histórico de otro país o fuera del período produzca un\n// falso positivo. Ejemplo confirmado: Isaac St Bernard tiene un perfil\n// inactivo de República Dominicana, terminado antes del 16-06-2026; ese\n// registro no es válido para confirmar su presencia en BambooHR TT.\nconst trinidadTobagoEmployees =\n allNormalized\n .filter(isTrinidadTobago)\n .map((employee) => ({\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'trinidad_tobago_country_or_location',\n }));\n\nconst relevantEmployeesOutsideCountry =\n allNormalized.filter((employee) => {\n if (isTrinidadTobago(employee)) return false;\n\n return (\n employee.normalized_aliases || []\n ).some((alias) =>\n relevantNames.has(alias)\n );\n });\n\nconst eligibleRelevantEmployeesOutsideCountry =\n relevantEmployeesOutsideCountry\n .filter((employee) =>\n employee.overlaps_period === true &&\n normalize(employee.status) === 'active'\n )\n .map((employee) => ({\n ...employee,\n validation_eligible: true,\n validation_scope:\n 'outside_country_exact_name_active_in_period',\n }));\n\nconst excludedRelevantEmployeesOutsideCountry =\n relevantEmployeesOutsideCountry\n .filter((employee) =>\n !(\n employee.overlaps_period === true &&\n normalize(employee.status) === 'active'\n )\n )\n .map((employee) => ({\n bamboo_id: employee.bamboo_id,\n employee_number: employee.employee_number,\n full_name: employee.full_name,\n country: employee.country,\n location: employee.location,\n status: employee.status,\n hire_date: employee.hire_date,\n termination_date:\n employee.termination_date,\n overlaps_period:\n employee.overlaps_period,\n exclusion_reason:\n employee.overlaps_period !== true\n ? 'outside_processing_period'\n : 'status_not_active',\n }));\n\nconst validationEmployeeMap = new Map();\n\nfor (const employee of [\n ...trinidadTobagoEmployees,\n ...eligibleRelevantEmployeesOutsideCountry,\n]) {\n const key =\n employee.bamboo_id ||\n employee.employee_number ||\n normalize(employee.full_name);\n\n if (key) {\n validationEmployeeMap.set(key, employee);\n }\n}\n\nconst bambooValidationEmployees =\n Array.from(validationEmployeeMap.values());\n\nconst fetchedEmployeesCount =\n rawEmployees.length;\n\nconst fetchComplete =\n expectedTotal > 0\n ? fetchedEmployeesCount >= expectedTotal\n : (\n pageObjects.length > 0 &&\n !pageObjects.some(\n (page) =>\n Boolean(\n page?._links?.next?.href\n )\n )\n );\n\nconst errors = [];\n\nif (!pageObjects.length) {\n errors.push(\n 'BambooHR no devolvió páginas de empleados.'\n );\n}\n\nif (!fetchedEmployeesCount) {\n errors.push(\n 'BambooHR no devolvió empleados.'\n );\n}\n\nif (\n expectedTotal > 0 &&\n fetchedEmployeesCount < expectedTotal\n) {\n errors.push(\n `La descarga de BambooHR quedó incompleta: ` +\n `${fetchedEmployeesCount} de ${expectedTotal} empleados.`\n );\n}\n\nif (!trinidadTobagoEmployees.length) {\n errors.push(\n 'No se encontraron empleados de Trinidad y Tobago en BambooHR.'\n );\n}\n\nreturn [\n {\n json: {\n ...base,\n ok:\n Boolean(base.ok ?? true) &&\n errors.length === 0,\n stage:\n errors.length === 0\n ? 'bamboohr_tt_normalizado'\n : 'bamboohr_tt_incompleto',\n errors: [\n ...(Array.isArray(base.errors)\n ? base.errors\n : []),\n ...errors,\n ],\n bamboo: {\n source:\n 'bamboohr_list_employees_paginado',\n period_start: periodStart,\n period_end: periodEnd,\n pages_fetched: pageObjects.length,\n expected_total: expectedTotal,\n raw_employees_count:\n fetchedEmployeesCount,\n employees_count:\n allNormalized.length,\n trinidad_tobago_count:\n trinidadTobagoEmployees.length,\n active_in_period_count:\n trinidadTobagoEmployees.filter(\n (employee) =>\n employee.overlaps_period\n ).length,\n active_status_count:\n trinidadTobagoEmployees.filter(\n (employee) =>\n normalize(employee.status) ===\n 'active'\n ).length,\n fetch_complete: fetchComplete,\n validation_available:\n fetchComplete &&\n bambooValidationEmployees.length > 0,\n validation_candidates_count:\n bambooValidationEmployees.length,\n relevant_outside_country_count:\n eligibleRelevantEmployeesOutsideCountry.length,\n relevant_outside_country_total_count:\n relevantEmployeesOutsideCountry.length,\n relevant_outside_country_excluded_count:\n excludedRelevantEmployeesOutsideCountry.length,\n relevant_outside_country_excluded:\n excludedRelevantEmployeesOutsideCountry,\n validation_rule:\n 'TT country/location, or exact contextual name that is Active and overlaps the period',\n employees:\n bambooValidationEmployees,\n restricted_fields:\n restrictedFields,\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
464,
896
],
"id": "bceb89b4-08e9-41c6-b412-f996a61bbcd9",
"name": "Normalizar BambooHR TT"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
1344,
1376
],
"id": "63b76f71-f399-4e62-8eac-1c60d0ad24cb",
"name": "Merge - Agregar BambooHR TT"
},
{
"parameters": {
"jsCode": "const data = $input.first().json || {};\n\nfunction roundMoney(value) {\n return Math.round((Number(value) || 0) * 100) / 100;\n}\n\nfunction moneyDiff(a, b) {\n return roundMoney((Number(a) || 0) - (Number(b) || 0));\n}\n\nfunction moneyEquals(a, b, tolerance = 0.02) {\n return Math.abs(roundMoney(a) - roundMoney(b)) <= tolerance;\n}\n\nfunction normalizeAccount(value) {\n return String(value ?? '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim();\n}\n\nfunction normalizeName(value) {\n return String(value ?? '')\n .toLowerCase()\n .normalize('NFD')\n .replace(/[\\u0300-\\u036f]/g, '')\n .replace(/['`-]/g, '')\n .replace(/[^a-z0-9 ]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction nameWords(value) {\n const ignored = new Set(['de', 'del', 'la', 'las', 'los', 'y', 'e', 'el']);\n return normalizeName(value)\n .split(' ')\n .filter((word) => word.length > 1 && !ignored.has(word));\n}\n\nfunction editDistance(a, b) {\n if (a === b) return 0;\n if (!a) return b.length;\n if (!b) return a.length;\n\n const previous = Array.from({ length: b.length + 1 }, (_, index) => index);\n\n for (let i = 1; i <= a.length; i++) {\n const current = [i];\n\n for (let j = 1; j <= b.length; j++) {\n const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n\n current[j] = Math.min(\n current[j - 1] + 1,\n previous[j] + 1,\n previous[j - 1] + cost\n );\n }\n\n for (let j = 0; j < current.length; j++) {\n previous[j] = current[j];\n }\n }\n\n return previous[b.length];\n}\n\nfunction tokenMatches(a, b) {\n if (a === b) return true;\n\n const minLength = Math.min(a.length, b.length);\n\n if (minLength >= 8 && editDistance(a, b) <= 2) return true;\n if (minLength >= 5 && editDistance(a, b) <= 1) return true;\n\n return false;\n}\n\nfunction bambooTokenMatches(a, b) {\n if (tokenMatches(a, b)) return true;\n\n const minLength = Math.min(a.length, b.length);\n const maxLength = Math.max(a.length, b.length);\n const distance = editDistance(a, b);\n\n // Tolera variaciones pequeñas de escritura entre Banco/Nómina y BambooHR,\n // por ejemplo Anessa <-> Annesa, sin flexibilizar el cruce principal.\n if (minLength >= 6 && distance <= 2) {\n return true;\n }\n\n // Permite apellidos compuestos como BeharrySingh vs Singh,\n // pero evita aceptar coincidencias demasiado amplias.\n return (\n minLength >= 4 &&\n maxLength - minLength <= 10 &&\n (\n a.startsWith(b) ||\n b.startsWith(a) ||\n a.endsWith(b) ||\n b.endsWith(a)\n )\n );\n}\n\nfunction samePersonName(a, b) {\n const normalizedA = normalizeName(a);\n const normalizedB = normalizeName(b);\n\n if (!normalizedA || !normalizedB) return false;\n if (normalizedA === normalizedB) return true;\n\n const compactA = normalizedA.replace(/\\s+/g, '');\n const compactB = normalizedB.replace(/\\s+/g, '');\n\n if (compactA === compactB) return true;\n\n const wordsA = nameWords(a);\n const wordsB = nameWords(b);\n\n if (!wordsA.length || !wordsB.length) return false;\n\n const usedB = new Set();\n let matches = 0;\n\n for (const wordA of wordsA) {\n const matchIndex = wordsB.findIndex((wordB, index) => {\n return !usedB.has(index) && tokenMatches(wordA, wordB);\n });\n\n if (matchIndex >= 0) {\n usedB.add(matchIndex);\n matches += 1;\n }\n }\n\n const smallerLength = Math.min(wordsA.length, wordsB.length);\n const ratio = matches / smallerLength;\n\n if (smallerLength <= 2) {\n return matches === smallerLength && matches >= 2;\n }\n\n return matches >= 2 && ratio >= 0.6;\n}\n\nfunction accountDistance(a, b) {\n return editDistance(normalizeAccount(a), normalizeAccount(b));\n}\n\nfunction accountRelationship(payrollAccount, bankAccount) {\n const payroll = normalizeAccount(payrollAccount);\n const bank = normalizeAccount(bankAccount);\n\n if (!payroll || !bank) {\n return { matches: false, type: 'none' };\n }\n\n if (payroll === bank) {\n return { matches: true, type: 'exact' };\n }\n\n const bankHasPayrollSuffix =\n bank.endsWith(payroll) &&\n bank.length > payroll.length &&\n bank.length - payroll.length <= 6;\n\n const payrollHasBankSuffix =\n payroll.endsWith(bank) &&\n payroll.length > bank.length &&\n payroll.length - bank.length <= 6;\n\n if (bankHasPayrollSuffix || payrollHasBankSuffix) {\n return { matches: true, type: 'reference_prefix' };\n }\n\n return { matches: false, type: 'none' };\n}\n\nfunction formatMoney(value) {\n return Math.abs(roundMoney(value)).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n}\n\nfunction bankNames(bank) {\n return Array.from(new Set([\n ...(Array.isArray(bank.bank_name_files) ? bank.bank_name_files : []),\n ...(Array.isArray(bank.bank_account_holders) ? bank.bank_account_holders : []),\n bank.bank_name_file || '',\n bank.bank_account_holder || '',\n ].filter(Boolean)));\n}\n\nfunction bankMatchesName(bank, payrollName) {\n return bankNames(bank).some((name) => samePersonName(payrollName, name));\n}\n\nfunction bestBankDisplayName(bank) {\n return (\n bank.bank_name_file ||\n bank.bank_account_holder ||\n bankNames(bank)[0] ||\n ''\n );\n}\n\nfunction bambooAliases(employee) {\n return Array.from(new Set([\n ...(Array.isArray(employee.aliases) ? employee.aliases : []),\n employee.full_name || '',\n [employee.first_name, employee.middle_name, employee.last_name]\n .filter(Boolean)\n .join(' '),\n [employee.preferred_name, employee.last_name]\n .filter(Boolean)\n .join(' '),\n ].map((value) => String(value || '').trim()).filter(Boolean)));\n}\n\nfunction bambooEmployeeNumber(employee) {\n return normalizeAccount(\n employee.employee_number ||\n employee.employeeNumber ||\n ''\n );\n}\n\nfunction isTrinidadTobagoBambooEmployee(employee) {\n const country = normalizeName(\n employee.country || ''\n );\n const location = normalizeName(\n employee.location || ''\n );\n\n return (\n country === 'tt' ||\n country === 'tto' ||\n country.includes('trinidad') ||\n country.includes('tobago') ||\n location === 'tt' ||\n location === 'tto' ||\n location.includes('trinidad') ||\n location.includes('tobago')\n );\n}\n\nfunction isBambooValidationEligible(employee) {\n // La versión corregida del normalizador declara este campo.\n if (employee.validation_eligible === true) {\n return true;\n }\n\n if (employee.validation_eligible === false) {\n return false;\n }\n\n // Compatibilidad defensiva si este nodo recibe datos de una ejecución\n // anterior: los perfiles de TT siguen siendo válidos. Un perfil de otro\n // país solo puede utilizarse cuando está Active y vigente en el período.\n if (isTrinidadTobagoBambooEmployee(employee)) {\n return true;\n }\n\n return (\n employee.overlaps_period === true &&\n normalizeName(employee.status) === 'active'\n );\n}\n\nfunction nameSimilarityScore(a, b) {\n const normalizedA = normalizeName(a);\n const normalizedB = normalizeName(b);\n\n if (!normalizedA || !normalizedB) return 0;\n if (normalizedA === normalizedB) return 1;\n\n const compactA = normalizedA.replace(/\\s+/g, '');\n const compactB = normalizedB.replace(/\\s+/g, '');\n\n if (compactA === compactB) return 1;\n\n const wordsA = nameWords(normalizedA);\n const wordsB = nameWords(normalizedB);\n\n if (!wordsA.length || !wordsB.length) return 0;\n\n const usedB = new Set();\n let matches = 0;\n\n for (const wordA of wordsA) {\n const index = wordsB.findIndex(\n (wordB, wordIndex) =>\n !usedB.has(wordIndex) &&\n bambooTokenMatches(wordA, wordB)\n );\n\n if (index >= 0) {\n usedB.add(index);\n matches += 1;\n }\n }\n\n if (matches < 2) return 0;\n\n const ratioToShorter =\n matches / Math.min(wordsA.length, wordsB.length);\n const ratioToLonger =\n matches / Math.max(wordsA.length, wordsB.length);\n\n return (\n ratioToShorter * 0.7 +\n ratioToLonger * 0.3\n );\n}\n\nfunction bankRowKey(row) {\n return [\n row.source_file || '',\n row.row_number || '',\n ].join('|');\n}\n\nfunction bankRowNames(row) {\n const rowKey = bankRowKey(row);\n\n const linkedPayrollNames =\n typeof linkedPayrollNamesByBankRow !== 'undefined'\n ? linkedPayrollNamesByBankRow.get(rowKey) || []\n : [];\n\n return Array.from(new Set([\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ...linkedPayrollNames,\n ].map((value) => String(value || '').trim()).filter(Boolean)));\n}\n\nfunction bankRowEmployeeNumbers(row) {\n const rowKey = bankRowKey(row);\n\n const linkedNumbers =\n typeof linkedPayrollNumbersByBankRow !== 'undefined'\n ? linkedPayrollNumbersByBankRow.get(rowKey) || []\n : [];\n\n return Array.from(new Set(\n linkedNumbers\n .map(normalizeAccount)\n .filter((value) => value.length >= 6)\n ));\n}\n\nfunction bankRowReferenceText(row) {\n return [\n row.reference || '',\n row.concept || '',\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ...bankRowEmployeeNumbers(row),\n ].join(' ');\n}\n\nfunction isClearlyNonEmployeePayment(row) {\n const normalized = normalizeName([\n row.concept || '',\n row.bank_name_file || '',\n row.bank_account_holder || '',\n ].join(' '));\n\n return [\n 'pension alimenticia',\n 'embargo judicial',\n 'retencion judicial',\n ].some((token) =>\n normalized.includes(normalizeName(token))\n );\n}\n\nfunction buildBambooSearchIndex(employees) {\n const records = [];\n const exactAliasMap = new Map();\n const tokenIndexSets = new Map();\n const employeeNumberMap = new Map();\n\n for (let index = 0; index < employees.length; index++) {\n const employee = employees[index];\n const aliases = bambooAliases(employee)\n .map((alias) => ({\n raw: alias,\n normalized: normalizeName(alias),\n }))\n .filter((alias) => alias.normalized);\n\n const uniqueAliases = [];\n const seenAliases = new Set();\n\n for (const alias of aliases) {\n if (seenAliases.has(alias.normalized)) continue;\n seenAliases.add(alias.normalized);\n uniqueAliases.push({\n ...alias,\n words: nameWords(alias.normalized),\n });\n\n const exact = exactAliasMap.get(alias.normalized) || [];\n exact.push(index);\n exactAliasMap.set(alias.normalized, exact);\n\n const uniqueTokens = Array.from(new Set(\n nameWords(alias.normalized)\n .filter((token) => token.length >= 3)\n ));\n\n for (const token of uniqueTokens) {\n const set = tokenIndexSets.get(token) || new Set();\n set.add(index);\n tokenIndexSets.set(token, set);\n }\n }\n\n const employeeNumber = bambooEmployeeNumber(employee);\n\n if (employeeNumber.length >= 6) {\n const matches = employeeNumberMap.get(employeeNumber) || [];\n matches.push(index);\n employeeNumberMap.set(employeeNumber, matches);\n }\n\n records.push({\n employee,\n aliases: uniqueAliases,\n employeeNumber,\n });\n }\n\n const tokenIndex = new Map();\n for (const [token, set] of tokenIndexSets.entries()) {\n tokenIndex.set(token, Array.from(set));\n }\n\n return {\n records,\n exactAliasMap,\n tokenIndex,\n employeeNumberMap,\n };\n}\n\nconst bambooMatchCache = new Map();\n\nfunction findBambooMatch(bankRow) {\n const names = bankRowNames(bankRow);\n const normalizedNames = Array.from(new Set(\n names.map(normalizeName).filter(Boolean)\n ));\n const directEmployeeNumbers = bankRowEmployeeNumbers(bankRow);\n const referenceNumberTokens = Array.from(new Set(\n (\n String(bankRowReferenceText(bankRow) || '')\n .match(/\\d{6,}/g) || []\n )\n .map(normalizeAccount)\n .filter((value) => value.length >= 6)\n ));\n\n const cacheKey = [\n ...directEmployeeNumbers.sort(),\n ...referenceNumberTokens.sort(),\n ...normalizedNames.sort(),\n ].join('|');\n\n if (bambooMatchCache.has(cacheKey)) {\n return bambooMatchCache.get(cacheKey);\n }\n\n const numberCandidateIndexes = new Set();\n\n for (const employeeNumber of directEmployeeNumbers) {\n for (\n const index of\n bambooSearch.employeeNumberMap.get(employeeNumber) || []\n ) {\n numberCandidateIndexes.add(index);\n }\n }\n\n if (!numberCandidateIndexes.size && referenceNumberTokens.length) {\n for (const referenceNumber of referenceNumberTokens) {\n for (\n const index of\n bambooSearch.employeeNumberMap.get(referenceNumber) || []\n ) {\n numberCandidateIndexes.add(index);\n }\n }\n }\n\n if (numberCandidateIndexes.size === 1) {\n const index = numberCandidateIndexes.values().next().value;\n const record = bambooSearch.records[index];\n\n // Un Employee Number enlazado desde la nómina es confiable.\n // Si proviene solamente de la referencia bancaria, también se exige\n // que el nombre corresponda para evitar falsos positivos por números\n // accidentales dentro del Addenda.\n const referenceNameScore = Math.max(\n 0,\n ...names.flatMap((currentBankName) =>\n record.aliases.map((alias) =>\n nameSimilarityScore(\n currentBankName,\n alias.normalized\n )\n )\n )\n );\n\n if (\n directEmployeeNumbers.length ||\n referenceNameScore >= 0.84\n ) {\n const result = {\n found: true,\n matched_by: directEmployeeNumbers.length\n ? 'employee_number_payroll'\n : 'employee_number_reference_and_name',\n confidence: directEmployeeNumbers.length\n ? 1\n : referenceNameScore,\n employee: record.employee,\n };\n bambooMatchCache.set(cacheKey, result);\n return result;\n }\n\n // La coincidencia numérica aislada se descarta y se continúa\n // con la validación por nombre.\n numberCandidateIndexes.clear();\n }\n\n const exactCandidateIndexes = new Set();\n\n for (const name of normalizedNames) {\n for (\n const index of\n bambooSearch.exactAliasMap.get(name) || []\n ) {\n exactCandidateIndexes.add(index);\n }\n }\n\n if (exactCandidateIndexes.size === 1) {\n const index = exactCandidateIndexes.values().next().value;\n const result = {\n found: true,\n matched_by: 'exact_name',\n confidence: 1,\n employee: bambooSearch.records[index].employee,\n bank_name: names[0] || '',\n bamboo_alias:\n bambooSearch.records[index].aliases[0]?.raw || '',\n };\n bambooMatchCache.set(cacheKey, result);\n return result;\n }\n\n // Confirmado manualmente en el directorio de BambooHR:\n // no debe aceptarse una coincidencia aproximada para este nombre.\n // Si en el futuro se crea el perfil con un alias exacto, el bloque\n // anterior lo reconocerá automáticamente antes de llegar aquí.\n const confirmedAbsentNames = new Set([\n 'isaac st bernard',\n ]);\n\n if (\n normalizedNames.some((name) =>\n confirmedAbsentNames.has(name)\n )\n ) {\n const result = {\n found: false,\n matched_by: null,\n confidence: 0,\n employee: null,\n ambiguous: false,\n best_candidate: null,\n reason: 'confirmed_absent_in_bamboohr',\n };\n bambooMatchCache.set(cacheKey, result);\n return result;\n }\n\n const candidateVotes = new Map();\n\n for (const name of normalizedNames) {\n const tokens = Array.from(new Set(\n nameWords(name)\n .filter((token) => token.length >= 3)\n ));\n\n for (const token of tokens) {\n const indexes = bambooSearch.tokenIndex.get(token) || [];\n\n // Evita que nombres demasiado comunes generen cientos de comparaciones.\n if (indexes.length > 180) continue;\n\n for (const index of indexes) {\n candidateVotes.set(\n index,\n (candidateVotes.get(index) || 0) + 1\n );\n }\n }\n }\n\n // Cuando una letra fue agregada, omitida o reemplazada, puede no existir\n // ningún token exacto compartido. En ese caso se buscan tokens cercanos\n // solamente entre palabras de longitud comparable.\n if (!candidateVotes.size) {\n for (const name of normalizedNames) {\n const queryTokens = Array.from(new Set(\n nameWords(name)\n .filter((token) => token.length >= 3)\n ));\n\n for (const queryToken of queryTokens) {\n for (\n const [indexedToken, indexes] of\n bambooSearch.tokenIndex.entries()\n ) {\n if (\n Math.abs(\n queryToken.length - indexedToken.length\n ) > 2\n ) {\n continue;\n }\n\n if (\n queryToken[0] !== indexedToken[0] &&\n queryToken.at(-1) !== indexedToken.at(-1)\n ) {\n continue;\n }\n\n if (\n !bambooTokenMatches(\n queryToken,\n indexedToken\n )\n ) {\n continue;\n }\n\n if (indexes.length > 180) continue;\n\n for (const index of indexes) {\n candidateVotes.set(\n index,\n (candidateVotes.get(index) || 0) + 0.75\n );\n }\n }\n }\n }\n }\n\n const candidateIndexes = Array.from(candidateVotes.entries())\n .sort((a, b) => b[1] - a[1])\n .slice(0, 180)\n .map(([index]) => index);\n\n let best = null;\n let second = null;\n\n for (const index of candidateIndexes) {\n const record = bambooSearch.records[index];\n let bestScoreForEmployee = 0;\n let bestBankName = '';\n let bestAlias = '';\n\n for (const currentBankName of names) {\n for (const alias of record.aliases) {\n const score = nameSimilarityScore(\n currentBankName,\n alias.normalized\n );\n\n if (score > bestScoreForEmployee) {\n bestScoreForEmployee = score;\n bestBankName = currentBankName;\n bestAlias = alias.raw;\n }\n }\n }\n\n if (bestScoreForEmployee <= 0) continue;\n\n const candidate = {\n employee: record.employee,\n score: bestScoreForEmployee,\n bank_name: bestBankName,\n bamboo_alias: bestAlias,\n };\n\n if (!best || candidate.score > best.score) {\n second = best;\n best = candidate;\n } else if (!second || candidate.score > second.score) {\n second = candidate;\n }\n }\n\n let result;\n\n if (\n best &&\n best.score >= 0.78 &&\n (!second || best.score - second.score >= 0.05)\n ) {\n result = {\n found: true,\n matched_by:\n normalizeName(best.bank_name) ===\n normalizeName(best.bamboo_alias)\n ? 'exact_name'\n : 'strong_name',\n confidence: best.score,\n employee: best.employee,\n bank_name: best.bank_name,\n bamboo_alias: best.bamboo_alias,\n };\n } else {\n result = {\n found: false,\n matched_by: null,\n confidence: best?.score || 0,\n employee: null,\n ambiguous: Boolean(\n best &&\n second &&\n best.score >= 0.7 &&\n best.score - second.score < 0.05\n ),\n best_candidate: best || null,\n };\n }\n\n bambooMatchCache.set(cacheKey, result);\n return result;\n}\n\nfunction supplementKey(supplement) {\n return [\n supplement.source_sheet || '',\n supplement.row_number || '',\n supplement.supplement_id || '',\n supplement.account || '',\n supplement.payroll_amount || 0,\n ].join('|');\n}\n\nconst payrollAccounts = (data.payroll?.grouped_by_account || [])\n .map((row) => ({\n ...row,\n group_key:\n row.group_key ||\n `${normalizeAccount(row.account)}:${row.currency || 'TTD'}`,\n account: normalizeAccount(row.account),\n employee_name: row.employee_name || row.employee || '',\n employee_number: row.employee_number || row.employeeNumber || '',\n currency: row.currency || 'TTD',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n source_rows: Array.isArray(row.source_rows) ? [...row.source_rows] : [],\n source_sheets: Array.isArray(row.source_sheets)\n ? [...row.source_sheets]\n : [],\n }))\n .filter((row) => row.account && row.payroll_amount > 0);\n\nconst payrollNoAccountRows = (data.payroll?.no_account_rows || [])\n .map((row) => ({\n ...row,\n account: '',\n employee_name: row.employee_name || row.employee || '',\n employee_number: row.employee_number || row.employeeNumber || '',\n currency: row.currency || 'TTD',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n }))\n .filter((row) => row.payroll_amount > 0);\n\nconst bankAccounts = (data.bank?.grouped_by_account || [])\n .map((row) => ({\n ...row,\n group_key:\n row.group_key ||\n `ACCOUNT:${normalizeAccount(row.account)}:${row.currency || 'TTD'}`,\n account: normalizeAccount(row.account),\n account_is_valid: Boolean(row.account_is_valid),\n currency: row.currency || 'TTD',\n amount: roundMoney(row.amount || row.bank_amount || row.bankAmount),\n source_rows: Array.isArray(row.source_rows) ? [...row.source_rows] : [],\n }))\n .filter((row) => row.amount > 0);\n\nconst rawBambooValidationEmployees =\n Array.isArray(data.bamboo?.employees)\n ? data.bamboo.employees\n : [];\n\nconst bambooEmployees =\n rawBambooValidationEmployees.filter(\n isBambooValidationEligible\n );\n\nconst excludedBambooValidationEmployees =\n rawBambooValidationEmployees\n .filter(\n (employee) =>\n !isBambooValidationEligible(employee)\n )\n .map((employee) => ({\n bamboo_id:\n employee.bamboo_id || '',\n employee_number:\n employee.employee_number || '',\n full_name:\n employee.full_name || '',\n country:\n employee.country || '',\n location:\n employee.location || '',\n status:\n employee.status || '',\n overlaps_period:\n Boolean(employee.overlaps_period),\n validation_scope:\n employee.validation_scope || '',\n }));\n\nconst bambooValidationAvailable =\n data.bamboo?.fetch_complete === true &&\n data.bamboo?.validation_available === true &&\n bambooEmployees.length > 0;\n\nconst bambooValidationWarning =\n bambooValidationAvailable\n ? null\n : (\n data.errors?.find((error) =>\n String(error || '').toLowerCase().includes('bamboohr')\n ) ||\n 'La validación Banco sin Bamboo no estuvo disponible porque la descarga de empleados de BambooHR quedó incompleta.'\n );\n\nconst bambooSearch = buildBambooSearchIndex(\n bambooEmployees\n);\n\nconst bankDetailRows = Array.isArray(data.bank?.rows)\n ? data.bank.rows\n : [];\n\nconst potentialSupplements = (\n data.payroll?.potential_supplements ||\n data.debug_payroll?.potential_supplements ||\n data.debug_payroll?.attached_supplements ||\n []\n)\n .map((row) => ({\n ...row,\n account: normalizeAccount(row.account),\n currency: row.currency || 'TTD',\n payroll_amount: roundMoney(row.payroll_amount || row.payrollAmount),\n }))\n .filter((row) => {\n const id = normalizeName(row.supplement_id || '');\n\n return (\n row.account &&\n row.payroll_amount >= 10 &&\n !id.includes('back up')\n );\n });\n\nconst supplementsByAccountCurrency = new Map();\n\nfor (const supplement of potentialSupplements) {\n const key = `${supplement.account}:${supplement.currency}`;\n const current = supplementsByAccountCurrency.get(key) || [];\n\n current.push(supplement);\n supplementsByAccountCurrency.set(key, current);\n}\n\nfunction chooseConditionalSupplements(payroll, bank) {\n const baseAmount = roundMoney(payroll.payroll_amount);\n const bankAmount = roundMoney(bank.amount);\n const candidates =\n supplementsByAccountCurrency.get(\n `${payroll.account}:${payroll.currency}`\n ) || [];\n\n if (\n !candidates.length ||\n bankAmount <= baseAmount + 0.02\n ) {\n return {\n selected: [],\n effectiveAmount: baseAmount,\n baseAmount,\n improvement: 0,\n };\n }\n\n const baseDifference = Math.abs(baseAmount - bankAmount);\n let bestSelected = [];\n let bestAmount = baseAmount;\n let bestDifference = baseDifference;\n\n if (candidates.length <= 12) {\n const combinations = 1 << candidates.length;\n\n for (let mask = 1; mask < combinations; mask++) {\n const selected = [];\n let selectedTotal = 0;\n\n for (let index = 0; index < candidates.length; index++) {\n if ((mask & (1 << index)) !== 0) {\n selected.push(candidates[index]);\n selectedTotal = roundMoney(\n selectedTotal + candidates[index].payroll_amount\n );\n }\n }\n\n const candidateAmount = roundMoney(baseAmount + selectedTotal);\n const candidateDifference = Math.abs(\n candidateAmount - bankAmount\n );\n\n if (candidateDifference < bestDifference) {\n bestSelected = selected;\n bestAmount = candidateAmount;\n bestDifference = candidateDifference;\n }\n }\n } else {\n const sorted = [...candidates].sort(\n (a, b) => b.payroll_amount - a.payroll_amount\n );\n\n let runningAmount = baseAmount;\n const selected = [];\n\n for (const candidate of sorted) {\n const nextAmount = roundMoney(\n runningAmount + candidate.payroll_amount\n );\n\n if (\n Math.abs(nextAmount - bankAmount) <\n Math.abs(runningAmount - bankAmount)\n ) {\n selected.push(candidate);\n runningAmount = nextAmount;\n }\n }\n\n bestSelected = selected;\n bestAmount = runningAmount;\n bestDifference = Math.abs(bestAmount - bankAmount);\n }\n\n const improvement = roundMoney(\n baseDifference - bestDifference\n );\n\n // Evita sumar valores accidentales o inmateriales, como un \"Asignado\" de Q1.\n if (!bestSelected.length || improvement < 5) {\n return {\n selected: [],\n effectiveAmount: baseAmount,\n baseAmount,\n improvement: 0,\n };\n }\n\n return {\n selected: bestSelected,\n effectiveAmount: roundMoney(bestAmount),\n baseAmount,\n improvement,\n };\n}\n\nfunction getDirectCandidates(payroll, matchedBankKeys) {\n return bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n\n if (!relationship.matches) return false;\n\n // Un sufijo de referencia solamente es válido cuando el nombre también\n // corresponde a la misma persona.\n if (\n relationship.type === 'reference_prefix' &&\n !bankMatchesName(bank, payroll.employee_name)\n ) {\n return false;\n }\n\n return true;\n })\n .map((bank) => {\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n const supplementDecision =\n chooseConditionalSupplements(payroll, bank);\n\n return {\n bank,\n relationship,\n supplementDecision,\n nameMatches: bankMatchesName(bank, payroll.employee_name),\n };\n })\n .sort((a, b) => {\n const exactDifference =\n Number(b.relationship.type === 'exact') -\n Number(a.relationship.type === 'exact');\n\n if (exactDifference !== 0) return exactDifference;\n\n const nameDifference =\n Number(b.nameMatches) - Number(a.nameMatches);\n\n if (nameDifference !== 0) return nameDifference;\n\n return (\n Math.abs(\n a.supplementDecision.effectiveAmount - a.bank.amount\n ) -\n Math.abs(\n b.supplementDecision.effectiveAmount - b.bank.amount\n )\n );\n });\n}\n\nfunction buildSources(payroll, selectedSupplements) {\n const supplementRows = selectedSupplements.map((row) => ({\n source_sheet: row.source_sheet,\n row_number: row.row_number,\n amount: row.payroll_amount,\n supplement_original_name:\n row.supplement_original_name || row.employee_name || '',\n supplement_id: row.supplement_id || '',\n applied_conditionally: true,\n }));\n\n const sourceRows = [\n ...(payroll.source_rows || []),\n ...supplementRows,\n ];\n\n const sourceSheets = Array.from(new Set([\n ...(payroll.source_sheets || []),\n ...selectedSupplements\n .map((row) => row.source_sheet)\n .filter(Boolean),\n ]));\n\n return { sourceRows, sourceSheets };\n}\n\nconst matchedPayrollKeys = new Set();\nconst matchedBankKeys = new Set();\nconst matchedNoAccountIndexes = new Set();\nconst appliedSupplementKeys = new Set();\nconst appliedSupplements = [];\nconst finalExactReconciliations = [];\nconst rows = [];\n\nfunction registerSupplements(selected) {\n for (const supplement of selected || []) {\n const key = supplementKey(supplement);\n\n if (!appliedSupplementKeys.has(key)) {\n appliedSupplementKeys.add(key);\n appliedSupplements.push(supplement);\n }\n }\n}\n\n// 1) Cuenta exacta o referencia con prefijo, y monto conciliado.\nfor (const payroll of payrollAccounts) {\n const candidates = getDirectCandidates(\n payroll,\n matchedBankKeys\n ).filter((candidate) => {\n return moneyEquals(\n candidate.supplementDecision.effectiveAmount,\n candidate.bank.amount\n );\n });\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(payroll, decision.selected);\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `match_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory:\n candidate.relationship.type === 'reference_prefix'\n ? 'referencia_bancaria_con_prefijo'\n : decision.selected.length\n ? 'cuenta_monto_y_suplemento_condicional'\n : 'cuenta_y_monto_coinciden',\n observation:\n candidate.relationship.type === 'reference_prefix'\n ? 'Conciliado por nombre, monto y referencia bancaria con prefijo.'\n : decision.selected.length\n ? 'Conciliado correctamente. Se aplicó un suplemento porque el banco mostró un pago adicional.'\n : 'Conciliado correctamente.',\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 2) Cuenta diferente, pero nombre y monto coinciden.\n// Se ejecuta antes de crear diferencias directas para resolver casos como\n// Ashly/Ashley Ramos: la cuenta de la nómina apunta a otra transacción,\n// pero existe otra cuenta bancaria con el mismo nombre y monto correcto.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n if (!bankMatchesName(bank, payroll.employee_name)) return false;\n\n const decision = chooseConditionalSupplements(\n payroll,\n bank\n );\n\n return moneyEquals(\n decision.effectiveAmount,\n bank.amount\n );\n })\n .map((bank) => ({\n bank,\n supplementDecision: chooseConditionalSupplements(\n payroll,\n bank\n ),\n }));\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const relationship = accountRelationship(\n payroll.account,\n bank.account\n );\n\n // Las referencias con prefijo ya debieron resolverse en el paso 1.\n if (relationship.type === 'reference_prefix') continue;\n\n const sources = buildSources(payroll, decision.selected);\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `possible_wrong_account_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name || bestBankDisplayName(bank),\n employee_name:\n payroll.employee_name || bestBankDisplayName(bank),\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'nombre_y_monto_coinciden_cuenta_diferente',\n observation:\n `El nombre y el monto coinciden, pero la cuenta de nómina ` +\n `(${payroll.account || 'sin cuenta'}) es diferente a la cuenta ` +\n `del banco (${bank.account || 'sin cuenta válida'}).`,\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 3) Nómina sin cuenta válida: conciliar por nombre y monto.\nfor (\n let index = 0;\n index < payrollNoAccountRows.length;\n index++\n) {\n const payroll = payrollNoAccountRows[index];\n\n const candidates = bankAccounts.filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n if (bank.currency !== payroll.currency) return false;\n if (!moneyEquals(bank.amount, payroll.payroll_amount)) {\n return false;\n }\n\n return bankMatchesName(bank, payroll.employee_name);\n });\n\n if (candidates.length !== 1) continue;\n\n const bank = candidates[0];\n\n matchedNoAccountIndexes.add(index);\n matchedBankKeys.add(bank.group_key);\n\n rows.push({\n id: `possible_missing_account_${index}_${bank.group_key}`,\n employee:\n payroll.employee_name || bestBankDisplayName(bank),\n employee_name:\n payroll.employee_name || bestBankDisplayName(bank),\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: bank.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'cuenta_faltante_en_nomina_nombre_y_monto_coinciden',\n observation:\n `El nombre y el monto coinciden, pero la nómina no tiene una cuenta bancaria válida registrada. El banco utilizó la cuenta ${bank.account}.`,\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n source_rows: [\n {\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n account: '',\n amount: payroll.payroll_amount,\n employee_name: payroll.employee_name,\n },\n ],\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 4) Diferencias reales en una cuenta exacta o equivalente.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = getDirectCandidates(\n payroll,\n matchedBankKeys\n );\n\n if (!candidates.length) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(payroll, decision.selected);\n const difference = moneyDiff(\n decision.effectiveAmount,\n bank.amount\n );\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n rows.push({\n id: `difference_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference,\n status: 'Riesgo',\n category: 'discrepancia',\n subcategory: 'diferencia_monto',\n observation:\n `Diferencia de ${payroll.currency} ` +\n `${formatMoney(difference)}.`,\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n\n// 4.5) Reconciliación final exacta de pares residuales.\n//\n// Este paso corrige casos en los que nómina y banco contienen:\n// - la misma cuenta normalizada;\n// - el mismo empleado;\n// - el mismo monto;\n// pero no fueron enlazados en los pasos anteriores por diferencias técnicas\n// de agrupación, moneda inferida o metadatos del CSV.\n//\n// Es deliberadamente conservador: exige una única contraparte bancaria.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n const candidates = bankAccounts\n .filter((bank) => {\n if (matchedBankKeys.has(bank.group_key)) return false;\n\n const payrollAccount = normalizeAccount(payroll.account);\n const bankAccount = normalizeAccount(bank.account);\n\n if (!payrollAccount || payrollAccount !== bankAccount) {\n return false;\n }\n\n if (!bankMatchesName(bank, payroll.employee_name)) {\n return false;\n }\n\n const decision = chooseConditionalSupplements(payroll, bank);\n\n return moneyEquals(\n decision.effectiveAmount,\n bank.amount\n );\n })\n .map((bank) => ({\n bank,\n supplementDecision: chooseConditionalSupplements(\n payroll,\n bank\n ),\n }));\n\n if (candidates.length !== 1) continue;\n\n const candidate = candidates[0];\n const bank = candidate.bank;\n const decision = candidate.supplementDecision;\n const sources = buildSources(\n payroll,\n decision.selected\n );\n\n matchedPayrollKeys.add(payroll.group_key);\n matchedBankKeys.add(bank.group_key);\n registerSupplements(decision.selected);\n\n finalExactReconciliations.push({\n employee_name: payroll.employee_name,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payroll_currency: payroll.currency,\n bank_currency: bank.currency,\n payroll_amount: decision.effectiveAmount,\n bank_amount: bank.amount,\n payroll_group_key: payroll.group_key,\n bank_group_key: bank.group_key,\n });\n\n rows.push({\n id: `final_exact_match_${payroll.group_key}_${bank.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: bank.currency || payroll.currency,\n payrollAmount: decision.effectiveAmount,\n payroll_amount: decision.effectiveAmount,\n payrollBaseAmount: decision.baseAmount,\n payroll_base_amount: decision.baseAmount,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: 0,\n status: 'Coincidencia',\n category: 'coincidencia',\n subcategory: 'reconciliacion_final_cuenta_nombre_monto',\n observation:\n 'Conciliado por cuenta, nombre y monto en la validación final.',\n applied_supplements: decision.selected,\n source_sheets: sources.sourceSheets,\n source_rows: sources.sourceRows,\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 5) Nómina con cuenta sin pago bancario.\nfor (const payroll of payrollAccounts) {\n if (matchedPayrollKeys.has(payroll.group_key)) continue;\n\n rows.push({\n id: `payroll_without_bank_${payroll.group_key}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: payroll.account,\n payrollAccount: payroll.account,\n payroll_account: payroll.account,\n bankAccount: '',\n bank_account: '',\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n payrollBaseAmount: payroll.payroll_amount,\n payroll_base_amount: payroll.payroll_amount,\n bankAmount: 0,\n bank_amount: 0,\n difference: payroll.payroll_amount,\n status: 'Riesgo',\n category: 'discrepancia',\n subcategory: 'nomina_con_cuenta_sin_pago_banco',\n observation:\n 'Está en nómina, pero no aparece pagado en el banco.',\n applied_supplements: [],\n source_sheets: payroll.source_sheets,\n source_rows: payroll.source_rows,\n });\n}\n\n// 6) Banco sin nómina.\nfor (const bank of bankAccounts) {\n if (matchedBankKeys.has(bank.group_key)) continue;\n\n rows.push({\n id: `bank_without_payroll_${bank.group_key}`,\n employee:\n bestBankDisplayName(bank) || 'Pago bancario sin nómina',\n employee_name:\n bestBankDisplayName(bank) || 'Pago bancario sin nómina',\n employeeNumber: '',\n employee_number: '',\n account: bank.account,\n payrollAccount: '',\n payroll_account: '',\n bankAccount: bank.account,\n bank_account: bank.account,\n currency: bank.currency,\n payrollAmount: 0,\n payroll_amount: 0,\n bankAmount: bank.amount,\n bank_amount: bank.amount,\n difference: roundMoney(0 - bank.amount),\n status: 'Pendiente revisión',\n category: 'banco_sin_nomina',\n subcategory: 'pago_banco_sin_fila_nomina',\n observation:\n 'Recibió un pago en el banco, pero no aparece en la nómina cargada.',\n bank_source_rows: bank.source_rows,\n });\n}\n\n// 7) Nómina sin cuenta que no pudo conciliarse.\nfor (\n let index = 0;\n index < payrollNoAccountRows.length;\n index++\n) {\n if (matchedNoAccountIndexes.has(index)) continue;\n\n const payroll = payrollNoAccountRows[index];\n\n rows.push({\n id:\n `payroll_without_account_` +\n `${payroll.source_sheet}_${payroll.row_number}`,\n employee: payroll.employee_name,\n employee_name: payroll.employee_name,\n employeeNumber: payroll.employee_number,\n employee_number: payroll.employee_number,\n account: '',\n payrollAccount: '',\n payroll_account: '',\n bankAccount: '',\n bank_account: '',\n currency: payroll.currency,\n payrollAmount: payroll.payroll_amount,\n payroll_amount: payroll.payroll_amount,\n bankAmount: 0,\n bank_amount: 0,\n difference: payroll.payroll_amount,\n status: 'Pendiente revisión',\n category: 'nomina_sin_cuenta',\n subcategory: 'nomina_sin_cuenta_bancaria',\n observation:\n 'Tiene monto en nómina, pero no tiene una cuenta bancaria válida para cruzar contra el banco.',\n source_sheet: payroll.source_sheet,\n row_number: payroll.row_number,\n });\n}\n\n// 8) Consolidar el mismo empleado cuando aparece con dos cuentas de nómina.\nconst originalRows = [...rows];\nconst usedRowIds = new Set();\nconst consolidatedRows = [];\n\nfor (const differenceRow of originalRows) {\n if (\n differenceRow.category !== 'discrepancia' ||\n differenceRow.subcategory !== 'diferencia_monto' ||\n usedRowIds.has(differenceRow.id)\n ) {\n continue;\n }\n\n const extraPayrollRow = originalRows.find((candidate) => {\n if (\n candidate.id === differenceRow.id ||\n usedRowIds.has(candidate.id) ||\n candidate.subcategory !==\n 'nomina_con_cuenta_sin_pago_banco' ||\n candidate.currency !== differenceRow.currency\n ) {\n return false;\n }\n\n const samePerson = samePersonName(\n differenceRow.employee_name || differenceRow.employee,\n candidate.employee_name || candidate.employee\n );\n\n const similarAccounts =\n accountDistance(\n differenceRow.account,\n candidate.account\n ) <= 2;\n\n const combinedPayroll = roundMoney(\n differenceRow.payroll_amount +\n candidate.payroll_amount\n );\n\n const totalMatches = moneyEquals(\n combinedPayroll,\n differenceRow.bank_amount\n );\n\n return samePerson && similarAccounts && totalMatches;\n });\n\n if (!extraPayrollRow) continue;\n\n usedRowIds.add(differenceRow.id);\n usedRowIds.add(extraPayrollRow.id);\n\n const totalPayroll = roundMoney(\n differenceRow.payroll_amount +\n extraPayrollRow.payroll_amount\n );\n\n const accounts = Array.from(new Set([\n differenceRow.account,\n extraPayrollRow.account,\n ].filter(Boolean)));\n\n consolidatedRows.push({\n id:\n `split_account_` +\n `${differenceRow.account}_${extraPayrollRow.account}`,\n employee: differenceRow.employee_name,\n employee_name: differenceRow.employee_name,\n employeeNumber:\n differenceRow.employee_number ||\n extraPayrollRow.employee_number ||\n '',\n employee_number:\n differenceRow.employee_number ||\n extraPayrollRow.employee_number ||\n '',\n account:\n differenceRow.bank_account ||\n differenceRow.account,\n payrollAccount: accounts.join(' / '),\n payroll_account: accounts.join(' / '),\n bankAccount: differenceRow.bank_account,\n bank_account: differenceRow.bank_account,\n currency: differenceRow.currency,\n payrollAmount: totalPayroll,\n payroll_amount: totalPayroll,\n bankAmount: differenceRow.bank_amount,\n bank_amount: differenceRow.bank_amount,\n difference: moneyDiff(\n totalPayroll,\n differenceRow.bank_amount\n ),\n status: 'Riesgo',\n category: 'posible_cuenta_mal_digitada',\n subcategory:\n 'mismo_empleado_con_cuentas_distintas_en_nomina',\n observation:\n `El total de nómina coincide con el banco, pero el empleado ` +\n `aparece con cuentas distintas en la nómina: ` +\n `${accounts.join(' y ')}. La cuenta utilizada por el banco ` +\n `fue ${differenceRow.bank_account}.`,\n applied_supplements:\n differenceRow.applied_supplements || [],\n source_sheets: Array.from(new Set([\n ...(differenceRow.source_sheets || []),\n ...(extraPayrollRow.source_sheets || []),\n ])),\n source_rows: [\n ...(differenceRow.source_rows || []),\n ...(extraPayrollRow.source_rows || []),\n ],\n bank_source_rows:\n differenceRow.bank_source_rows || [],\n });\n}\n\nconst coreRows = [\n ...originalRows.filter(\n (row) => !usedRowIds.has(row.id)\n ),\n ...consolidatedRows,\n];\n\nconst coreCoincidencias = coreRows.filter(\n (row) => row.category === 'coincidencia'\n).length;\n\nconst coreDiscrepancias = coreRows.filter(\n (row) => row.category === 'discrepancia'\n).length;\n\nconst coreBancoSinNomina = coreRows.filter(\n (row) => row.category === 'banco_sin_nomina'\n).length;\n\nconst coreNominaSinCuenta = coreRows.filter(\n (row) => row.category === 'nomina_sin_cuenta'\n).length;\n\nconst corePosiblesCuentas = coreRows.filter(\n (row) => row.category === 'posible_cuenta_mal_digitada'\n).length;\n\nconst linkedPayrollNamesByBankRow = new Map();\nconst linkedPayrollNumbersByBankRow = new Map();\n\nfor (const reconciliationRow of coreRows) {\n const linkedName =\n reconciliationRow.employee_name ||\n reconciliationRow.employee ||\n '';\n const linkedEmployeeNumber = normalizeAccount(\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n ''\n );\n\n for (\n const bankSourceRow of\n reconciliationRow.bank_source_rows || []\n ) {\n const rowKey = bankRowKey(bankSourceRow);\n\n const names =\n linkedPayrollNamesByBankRow.get(rowKey) || [];\n const numbers =\n linkedPayrollNumbersByBankRow.get(rowKey) || [];\n\n if (linkedName) names.push(linkedName);\n if (linkedEmployeeNumber.length >= 6) {\n numbers.push(linkedEmployeeNumber);\n }\n\n linkedPayrollNamesByBankRow.set(\n rowKey,\n Array.from(new Set(names))\n );\n linkedPayrollNumbersByBankRow.set(\n rowKey,\n Array.from(new Set(numbers))\n );\n }\n}\n\nconst bambooMatchDetails = [];\nconst bambooExcludedPayments = [];\nconst bankWithoutBambooMap = new Map();\n\nif (bambooValidationAvailable) {\nfor (const bankRow of bankDetailRows) {\n if (isClearlyNonEmployeePayment(bankRow)) {\n bambooExcludedPayments.push({\n source_file: bankRow.source_file,\n row_number: bankRow.row_number,\n reason: 'pago_no_empleado_identificado',\n bank_name_file: bankRow.bank_name_file,\n bank_account_holder:\n bankRow.bank_account_holder,\n amount: bankRow.amount,\n currency: bankRow.currency,\n });\n continue;\n }\n\n const match = findBambooMatch(bankRow);\n\n if (match.found) {\n bambooMatchDetails.push({\n source_file: bankRow.source_file,\n row_number: bankRow.row_number,\n account: bankRow.account,\n amount: bankRow.amount,\n currency: bankRow.currency,\n bank_name_file: bankRow.bank_name_file,\n bank_account_holder:\n bankRow.bank_account_holder,\n matched_by: match.matched_by,\n confidence: roundMoney(match.confidence),\n bamboo_employee_number:\n match.employee?.employee_number || '',\n bamboo_employee_name:\n match.employee?.full_name || '',\n bamboo_status:\n match.employee?.status || '',\n bamboo_country:\n match.employee?.country || '',\n bamboo_location:\n match.employee?.location || '',\n bamboo_validation_scope:\n match.employee?.validation_scope || '',\n bamboo_overlaps_period:\n Boolean(match.employee?.overlaps_period),\n });\n continue;\n }\n\n const displayName =\n bankRow.bank_name_file ||\n bankRow.bank_account_holder ||\n 'Pago bancario sin empleado identificado';\n\n const groupingKey = [\n normalizeAccount(bankRow.account),\n normalizeName(displayName),\n bankRow.currency || 'TTD',\n ].join('|');\n\n const current =\n bankWithoutBambooMap.get(groupingKey) || {\n id: `bank_without_bamboo_${groupingKey}`,\n employee: displayName,\n employee_name: displayName,\n bank_name_file:\n bankRow.bank_name_file || '',\n bank_account_holder:\n bankRow.bank_account_holder || '',\n account: normalizeAccount(bankRow.account),\n bankAccount: normalizeAccount(bankRow.account),\n bank_account: normalizeAccount(bankRow.account),\n currency: bankRow.currency || 'TTD',\n bankAmount: 0,\n bank_amount: 0,\n shipment_numbers: new Set(),\n references: new Set(),\n source_files: new Set(),\n source_rows: [],\n status: 'Pendiente revisión',\n category: 'banco_sin_bamboo',\n subcategory:\n 'pago_bancario_sin_empleado_bamboohr_tt',\n observation:\n 'Se encontró un pago en el banco, pero no se encontró una coincidencia confiable con un empleado de Trinidad y Tobago en BambooHR.',\n best_bamboo_candidate:\n match.best_candidate\n ? {\n employee_number:\n match.best_candidate.employee\n ?.employee_number || '',\n employee_name:\n match.best_candidate.employee\n ?.full_name || '',\n score: roundMoney(\n match.best_candidate.score\n ),\n }\n : null,\n ambiguous_bamboo_match:\n Boolean(match.ambiguous),\n };\n\n current.bankAmount = roundMoney(\n current.bankAmount +\n Number(bankRow.amount || 0)\n );\n current.bank_amount = current.bankAmount;\n\n if (bankRow.shipment_number) {\n current.shipment_numbers.add(\n bankRow.shipment_number\n );\n }\n\n if (bankRow.reference) {\n current.references.add(bankRow.reference);\n }\n\n if (bankRow.source_file) {\n current.source_files.add(\n bankRow.source_file\n );\n }\n\n current.source_rows.push(bankRow);\n bankWithoutBambooMap.set(\n groupingKey,\n current\n );\n}\n}\n\nconst bankWithoutBamboo = Array.from(\n bankWithoutBambooMap.values()\n).map((row) => ({\n ...row,\n shipment_numbers: Array.from(\n row.shipment_numbers\n ),\n references: Array.from(row.references),\n source_files: Array.from(row.source_files),\n difference: roundMoney(\n 0 - row.bank_amount\n ),\n}));\n\nconst nameDifferenceMap = new Map();\n\nfor (const reconciliationRow of coreRows) {\n const payrollName = String(\n reconciliationRow.employee_name ||\n reconciliationRow.employee ||\n ''\n ).trim();\n\n if (!payrollName) continue;\n\n for (\n const bankSourceRow of\n reconciliationRow.bank_source_rows || []\n ) {\n const bankName = String(\n bankSourceRow.bank_name_file ||\n bankSourceRow.participant_name ||\n bankSourceRow.bank_account_holder ||\n ''\n ).trim();\n\n if (\n !bankName ||\n samePersonName(payrollName, bankName)\n ) {\n continue;\n }\n\n const account = normalizeAccount(\n bankSourceRow.account ||\n reconciliationRow.bank_account ||\n reconciliationRow.bankAccount ||\n reconciliationRow.account ||\n ''\n );\n\n const key = [\n normalizeName(payrollName),\n normalizeName(bankName),\n account,\n bankSourceRow.source_file || '',\n bankSourceRow.row_number || '',\n ].join('|');\n\n if (nameDifferenceMap.has(key)) {\n continue;\n }\n\n nameDifferenceMap.set(key, {\n id: `bank_name_difference_${key}`,\n employee: payrollName,\n employee_name: payrollName,\n payroll_name: payrollName,\n bank_name: bankName,\n employeeNumber:\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n '',\n employee_number:\n reconciliationRow.employee_number ||\n reconciliationRow.employeeNumber ||\n '',\n account,\n payrollAccount:\n reconciliationRow.payroll_account ||\n reconciliationRow.payrollAccount ||\n '',\n payroll_account:\n reconciliationRow.payroll_account ||\n reconciliationRow.payrollAccount ||\n '',\n bankAccount: account,\n bank_account: account,\n currency:\n bankSourceRow.currency ||\n reconciliationRow.currency ||\n 'TTD',\n payrollAmount:\n reconciliationRow.payroll_amount ||\n reconciliationRow.payrollAmount ||\n 0,\n payroll_amount:\n reconciliationRow.payroll_amount ||\n reconciliationRow.payrollAmount ||\n 0,\n bankAmount:\n bankSourceRow.amount || 0,\n bank_amount:\n bankSourceRow.amount || 0,\n difference: 0,\n status: 'Pendiente revisión',\n category: 'diferencia_nombre_banco',\n subcategory:\n 'nombre_nomina_vs_participante_banco',\n observation:\n `El nombre registrado en la nómina (${payrollName}) ` +\n `es diferente al nombre enviado al banco (${bankName}).`,\n bank_name_file: payrollName,\n bank_account_holder: bankName,\n source_file:\n bankSourceRow.source_file || '',\n financial_institution_id:\n bankSourceRow.financial_institution_id || '',\n reference:\n bankSourceRow.reference || '',\n row_number:\n bankSourceRow.row_number || '',\n });\n }\n}\n\nconst nameDifferenceRows = Array.from(\n nameDifferenceMap.values()\n);\n\nfunction priority(row) {\n const category = String(\n row.category || ''\n ).toLowerCase();\n\n if (category === 'posible_cuenta_mal_digitada') return 1;\n if (category === 'discrepancia') return 2;\n if (category === 'banco_sin_nomina') return 3;\n if (category === 'nomina_sin_cuenta') return 4;\n if (category === 'diferencia_nombre_banco') return 5;\n if (category === 'coincidencia') return 99;\n\n return 50;\n}\n\nconst rowsFinales = [\n ...coreRows,\n ...nameDifferenceRows,\n].sort((a, b) => {\n const priorityDifference =\n priority(a) - priority(b);\n\n if (priorityDifference !== 0) {\n return priorityDifference;\n }\n\n return String(\n a.employee_name || ''\n ).localeCompare(\n String(b.employee_name || ''),\n 'es'\n );\n});\n\nconst appliedSupplementsTotal = roundMoney(\n appliedSupplements.reduce(\n (sum, row) => sum + row.payroll_amount,\n 0\n )\n);\n\nconst totalNominaBase = roundMoney(\n data.payroll?.total_amount || 0\n);\n\nconst totalNomina = roundMoney(\n totalNominaBase + appliedSupplementsTotal\n);\n\nconst totalBanco = roundMoney(\n data.bank?.total_amount || 0\n);\n\nconst diferenciasNombreBanco =\n nameDifferenceRows.length;\n\nconst pendientes =\n coreDiscrepancias +\n coreBancoSinNomina +\n coreNominaSinCuenta +\n corePosiblesCuentas +\n bankWithoutBamboo.length +\n diferenciasNombreBanco;\n\nconst unusedPotentialSupplements =\n potentialSupplements.filter((row) => {\n return !appliedSupplementKeys.has(\n supplementKey(row)\n );\n });\n\nreturn [\n {\n json: {\n ok: true,\n stage: 'cruce_nomina_tt_banco',\n errors: [],\n metadata: data.metadata || {},\n summary: {\n coincidencias: coreCoincidencias,\n // La tarjeta de la app agrupa todos los casos de discrepancia/riesgo.\n // Se conserva el detalle puro en discrepanciasMontoPago.\n discrepancias:\n coreDiscrepancias + corePosiblesCuentas,\n discrepanciasMontoPago:\n coreDiscrepancias,\n bancoSinNomina: coreBancoSinNomina,\n bancoSinBamboo: bankWithoutBamboo.length,\n nominaSinCuenta: coreNominaSinCuenta,\n diferenciasNombreBanco,\n posiblesCuentasMalDigitadas:\n corePosiblesCuentas,\n totalResultados:\n coreCoincidencias +\n coreDiscrepancias +\n coreBancoSinNomina +\n coreNominaSinCuenta +\n corePosiblesCuentas +\n bankWithoutBamboo.length +\n diferenciasNombreBanco,\n pendientes,\n filasNominaValidas:\n data.payroll?.valid_rows_count || 0,\n filasNominaSinCuenta:\n data.payroll?.no_account_rows_count || 0,\n suplementosPotenciales:\n potentialSupplements.length,\n suplementosNominaAplicados:\n appliedSupplements.length,\n suplementosNominaNoAplicados:\n unusedPotentialSupplements.length,\n suplementosNominaAdjuntados:\n appliedSupplements.length,\n suplementosNominaNoAdjuntados:\n data.payroll?.unattached_supplements_count || 0,\n reconciliacionesExactasFinales:\n finalExactReconciliations.length,\n cuentasNominaAgrupadas:\n payrollAccounts.length,\n transaccionesBanco:\n data.bank?.rows_count || 0,\n cuentasBancoAgrupadas:\n bankAccounts.length,\n empleadosBambooTT:\n Number(\n data.bamboo?.trinidad_tobago_count ||\n bambooEmployees.length\n ),\n empleadosBambooEnPeriodo:\n Number(\n data.bamboo?.active_in_period_count || 0\n ),\n bambooPaginasDescargadas:\n Number(\n data.bamboo?.pages_fetched || 0\n ),\n bambooEmpleadosEsperados:\n Number(\n data.bamboo?.expected_total || 0\n ),\n bambooDescargaCompleta:\n Boolean(\n data.bamboo?.fetch_complete\n ),\n bambooValidacionDisponible:\n bambooValidationAvailable,\n totalNominaBase,\n totalSuplementosAplicados:\n appliedSupplementsTotal,\n totalNomina,\n totalBanco,\n diferenciaTotal:\n moneyDiff(totalNomina, totalBanco),\n },\n rows: rowsFinales,\n bankWithoutBamboo,\n nameDifferences: nameDifferenceRows,\n bambooSummary: data.bamboo || {},\n reportUrl: null,\n debug: {\n sheet_summaries:\n data.payroll?.sheet_summaries || [],\n potential_supplements:\n potentialSupplements,\n applied_supplements:\n appliedSupplements,\n final_exact_reconciliations:\n finalExactReconciliations,\n bamboo_search:\n {\n employees_received:\n rawBambooValidationEmployees.length,\n employees_indexed:\n bambooSearch.records.length,\n employees_excluded:\n excludedBambooValidationEmployees.length,\n excluded_employees:\n excludedBambooValidationEmployees,\n exact_aliases:\n bambooSearch.exactAliasMap.size,\n indexed_tokens:\n bambooSearch.tokenIndex.size,\n cache_entries:\n bambooMatchCache.size,\n },\n bamboo_matches:\n bambooMatchDetails,\n bamboo_excluded_payments:\n bambooExcludedPayments,\n bamboo_validation_available:\n bambooValidationAvailable,\n bamboo_validation_warning:\n bambooValidationWarning,\n banco_sin_bamboo:\n bankWithoutBamboo,\n unused_potential_supplements:\n unusedPotentialSupplements,\n unattached_supplements:\n data.debug_payroll?.unattached_supplements || [],\n payroll_preview:\n payrollAccounts.slice(0, 10),\n bank_preview:\n bankAccounts.slice(0, 10),\n payroll_no_account_preview:\n payrollNoAccountRows.slice(0, 10),\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1600,
1376
],
"id": "d510b344-a903-40d4-9f07-3ebf7c7a3301",
"name": "Cruzar Nómina vs Banco"
},
{
"parameters": {
"jsCode": "const data = $input.first().json || {};\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .replace(/\\uFEFF/g, '')\n .replace(/\\u00A0/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction roundMoney(value) {\n return Math.round(\n (Number(value) || 0) * 100\n ) / 100;\n}\n\nfunction firstValue(value) {\n if (Array.isArray(value)) {\n return value\n .map(normalizeText)\n .filter(Boolean)\n .join(' / ');\n }\n\n return normalizeText(value);\n}\n\nfunction formatPeriodEnd(value) {\n const raw = normalizeText(value);\n\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(raw)) {\n return raw;\n }\n\n const [year, month, day] = raw.split('-');\n\n const monthNames = {\n '01': 'ene',\n '02': 'feb',\n '03': 'mar',\n '04': 'abr',\n '05': 'may',\n '06': 'jun',\n '07': 'jul',\n '08': 'ago',\n '09': 'sep',\n '10': 'oct',\n '11': 'nov',\n '12': 'dic',\n };\n\n return `${day}-${monthNames[month] || month}-${year}`;\n}\n\nfunction mainReportSense(row, difference) {\n const subcategory = normalizeText(\n row.subcategory\n ).toLowerCase();\n\n const payrollAmount = Number(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = Number(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n if (\n subcategory ===\n 'nomina_con_cuenta_sin_pago_banco' ||\n (bankAmount === 0 && payrollAmount > 0)\n ) {\n return 'No aparece pagado en banco';\n }\n\n if (difference > 0) {\n return 'Se pagó de menos';\n }\n\n if (difference < 0) {\n return 'Se pagó de más';\n }\n\n return 'Revisar';\n}\n\nfunction accountValues(value) {\n const values = Array.isArray(value)\n ? value\n : String(value ?? '')\n .split(/\\s*(?:\\/|;|,|\\by\\b)\\s*/i);\n\n return values\n .map((item) =>\n String(item ?? '')\n .replace(/\\u00A0/g, '')\n .replace(/\\.0$/g, '')\n .replace(/\\D/g, '')\n .trim()\n )\n .filter(\n (account) =>\n account.length >= 6 &&\n !/^0+$/.test(account)\n );\n}\n\nfunction payrollAccountsForWrongAccount(row) {\n const candidates = [\n row.payroll_account,\n row.payrollAccount,\n ...(Array.isArray(row.source_rows)\n ? row.source_rows.flatMap(\n (sourceRow) => [\n sourceRow.account,\n sourceRow.payroll_account,\n sourceRow.payrollAccount,\n ]\n )\n : []),\n ];\n\n return Array.from(\n new Set(\n candidates.flatMap(accountValues)\n )\n );\n}\n\nfunction bankAccountForWrongAccount(row) {\n return firstValue(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n );\n}\n\nfunction moneyLabel(value) {\n return Math.abs(\n roundMoney(value)\n ).toLocaleString('en-US', {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n });\n}\n\nfunction wrongAccountTotalStatus(row) {\n const payrollAmount = roundMoney(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n const difference = roundMoney(\n payrollAmount - bankAmount\n );\n\n if (Math.abs(difference) <= 0.02) {\n return (\n 'El total de nómina coincide con ' +\n 'el total pagado por el banco.'\n );\n }\n\n if (difference > 0) {\n return (\n 'El total de nómina supera el total ' +\n `del banco por TT$${moneyLabel(difference)}.`\n );\n }\n\n return (\n 'El total pagado por el banco supera ' +\n `el total de nómina por TT$${moneyLabel(difference)}.`\n );\n}\n\nfunction wrongAccountFinding(row) {\n const existing = normalizeText(\n row.observation || ''\n );\n\n if (existing) return existing;\n\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n return (\n 'El empleado presenta una posible ' +\n 'inconsistencia entre la cuenta registrada ' +\n `en nómina (${payrollAccounts.join(' y ') || 'sin cuenta identificada'}) ` +\n `y la cuenta utilizada por el banco (${bankAccount || 'sin cuenta identificada'}).`\n );\n}\n\nconst metadata = data.metadata || {};\nconst summary = data.summary || {};\n\nconst rows = Array.isArray(data.rows)\n ? data.rows\n : [];\n\nconst bankWithoutBamboo =\n Array.isArray(data.bankWithoutBamboo)\n ? data.bankWithoutBamboo\n : [];\n\nconst periodLabel =\n metadata.period_label ||\n `${metadata.year || ''}-${metadata.month || ''}-${metadata.period_type || ''}`;\n\nconst periodEndLabel = formatPeriodEnd(\n metadata.period_end || ''\n);\n\nconst spreadsheetTitle =\n `Cruce de Cuentas GLM TT - ${periodLabel}`;\n\nconst sheetIds = {\n nominaVsBanco: 201,\n bancoSinNomina: 202,\n bancoSinBamboo: 203,\n diferenciasNombreBanco: 204,\n cuentaMalDigitada: 205,\n resumen: 206,\n};\n\nconst cuentaMalDigitadaCases = rows.filter(\n (row) =>\n row.category ===\n 'posible_cuenta_mal_digitada'\n);\n\nconst hasCuentaMalDigitada =\n cuentaMalDigitadaCases.length > 0;\n\nconst sheetTitles = {\n nominaVsBanco:\n '01 Nómina vs Banco',\n bancoSinNomina:\n '02 Banco sin Nómina',\n bancoSinBamboo:\n '03 Banco sin Bamboo',\n diferenciasNombreBanco:\n '04 Diferencias de Nombre',\n cuentaMalDigitada:\n '05 Cuenta Mal Digitada',\n resumen: hasCuentaMalDigitada\n ? '06 Resumen'\n : '05 Resumen',\n};\n\nconst mainRows = rows\n .filter(\n (row) =>\n row.category === 'discrepancia'\n )\n .map((row, index) => {\n const payrollAmount = roundMoney(\n row.payroll_amount ??\n row.payrollAmount ??\n 0\n );\n\n const bankAmount = roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n );\n\n const difference = roundMoney(\n row.difference ??\n (payrollAmount - bankAmount)\n );\n\n return [\n index + 1,\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.payroll_account ||\n row.payrollAccount ||\n row.account ||\n ''\n ),\n payrollAmount,\n bankAmount,\n difference,\n mainReportSense(\n row,\n difference\n ),\n normalizeText(\n row.status || 'Riesgo'\n ).toUpperCase(),\n ];\n });\n\nconst bancoSinNominaRows = rows\n .filter(\n (row) =>\n row.category ===\n 'banco_sin_nomina'\n )\n .map((row, index) => [\n index + 1,\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n firstValue(\n row.source_files ||\n row.source_file ||\n ''\n ),\n normalizeText(\n row.status ||\n 'Pendiente revisión'\n ).toUpperCase(),\n normalizeText(\n row.observation || ''\n ),\n ]);\n\nconst bancoSinBambooRows =\n bankWithoutBamboo.map(\n (row, index) => [\n index + 1,\n normalizeText(\n row.bank_name_file ||\n row.employee_name ||\n row.employee ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n firstValue(\n row.source_files ||\n row.source_file ||\n ''\n ),\n 'PENDIENTE REVISIÓN',\n ]\n );\n\nconst diferenciasNombreRows = rows\n .filter(\n (row) =>\n row.category ===\n 'diferencia_nombre_banco'\n )\n .map((row, index) => [\n index + 1,\n normalizeText(\n row.payroll_name ||\n row.employee_name ||\n row.employee ||\n row.bank_name_file ||\n ''\n ),\n normalizeText(\n row.bank_name ||\n row.bank_account_holder ||\n ''\n ),\n normalizeText(\n row.bank_account ||\n row.bankAccount ||\n row.account ||\n ''\n ),\n roundMoney(\n row.bank_amount ??\n row.bankAmount ??\n 0\n ),\n normalizeText(\n row.status ||\n 'Pendiente revisión'\n ).toUpperCase(),\n normalizeText(\n row.observation || ''\n ),\n ]);\n\nconst cuentaMalDigitadaRows = [];\n\ncuentaMalDigitadaCases.forEach(\n (row, index) => {\n const payrollAccounts =\n payrollAccountsForWrongAccount(row);\n\n const bankAccount =\n bankAccountForWrongAccount(row);\n\n const fields = [\n [\n 'Empleado',\n normalizeText(\n row.employee_name ||\n row.employee ||\n ''\n ),\n ],\n [\n 'Cuentas registradas en las hojas de nómina',\n payrollAccounts.join(' y ') ||\n 'No se identificó una cuenta válida en la nómina.',\n ],\n [\n 'Cuenta utilizada por el banco',\n bankAccount ||\n 'No se identificó una cuenta válida en el banco.',\n ],\n [\n 'Estado del total',\n wrongAccountTotalStatus(row),\n ],\n [\n 'Hallazgo',\n wrongAccountFinding(row),\n ],\n [\n 'Clasificación',\n 'Posible cuenta mal digitada — revisar y unificar la cuenta registrada en nómina.',\n ],\n ];\n\n fields.forEach(\n (field, fieldIndex) => {\n cuentaMalDigitadaRows.push([\n fieldIndex === 0\n ? index + 1\n : '',\n field[0],\n field[1],\n ]);\n }\n );\n }\n);\n\nconst resumenRows = [\n ['Período', periodLabel],\n [\n 'Coincidencias',\n Number(summary.coincidencias || 0),\n ],\n [\n 'Discrepancias de monto o pago',\n Number(\n summary.discrepanciasMontoPago ??\n summary.discrepancias ??\n 0\n ),\n ],\n [\n 'Banco sin nómina',\n Number(summary.bancoSinNomina || 0),\n ],\n [\n 'Banco sin Bamboo',\n Number(summary.bancoSinBamboo || 0),\n ],\n [\n 'Nómina sin cuenta no conciliada',\n Number(summary.nominaSinCuenta || 0),\n ],\n [\n 'Diferencias de nombre',\n Number(\n summary.diferenciasNombreBanco || 0\n ),\n ],\n [\n 'Posibles cuentas mal digitadas',\n Number(\n summary.posiblesCuentasMalDigitadas || 0\n ),\n ],\n [\n 'Pendientes del cruce principal',\n Number(summary.pendientes || 0),\n ],\n [\n 'Empleados BambooHR Trinidad y Tobago',\n Number(summary.empleadosBambooTT || 0),\n ],\n [\n 'Empleados BambooHR en el período',\n Number(\n summary.empleadosBambooEnPeriodo || 0\n ),\n ],\n [\n 'Filas válidas de nómina',\n Number(\n summary.filasNominaValidas || 0\n ),\n ],\n [\n 'Filas de nómina sin cuenta detectadas',\n Number(\n summary.filasNominaSinCuenta || 0\n ),\n ],\n [\n 'Transacciones bancarias',\n Number(\n summary.transaccionesBanco || 0\n ),\n ],\n [\n 'Total nómina',\n roundMoney(summary.totalNomina || 0),\n ],\n [\n 'Total banco',\n roundMoney(summary.totalBanco || 0),\n ],\n [\n 'Diferencia total',\n roundMoney(\n summary.diferenciaTotal || 0\n ),\n ],\n];\n\nfunction reportValues(\n title,\n subtitle,\n header,\n body\n) {\n return [\n [\n title,\n ...Array(\n Math.max(header.length - 1, 0)\n ).fill(''),\n ],\n [\n subtitle,\n ...Array(\n Math.max(header.length - 1, 0)\n ).fill(''),\n ],\n Array(header.length).fill(''),\n header,\n ...body,\n ];\n}\n\nconst nominaVsBancoValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Diferencias de Monto Nómina vs. Banco · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Empleado',\n 'Cuenta',\n 'Monto en Nómina (TT$)',\n 'Monto en Banco (TT$)',\n 'Diferencia (TT$)',\n 'Sentido',\n 'Estado',\n ],\n mainRows\n );\n\nconst bancoSinNominaValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Pagos bancarios sin registro en la nómina · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Archivo',\n 'Estado',\n 'Observación',\n ],\n bancoSinNominaRows\n );\n\nconst bancoSinBambooValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Pagos en banco sin empleado identificado en BambooHR · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Archivo',\n 'Estado',\n ],\n bancoSinBambooRows\n );\n\nconst diferenciasNombreValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Diferencias de nombre entre nómina y banco · Trinidad y Tobago · ${periodEndLabel}`,\n [\n '#',\n 'Nombre en nómina',\n 'Nombre en banco',\n 'Cuenta',\n 'Monto en banco (TT$)',\n 'Estado',\n 'Observación',\n ],\n diferenciasNombreRows\n );\n\nconst cuentaMalDigitadaValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Cuenta Mal Digitada en Nómina · Trinidad y Tobago · ${periodEndLabel}`,\n ['#', 'Campo', 'Detalle'],\n cuentaMalDigitadaRows\n );\n\nconst resumenValues =\n reportValues(\n 'GOMEZLEE MARKETING',\n `Resumen del cruce Nómina vs. Banco · Trinidad y Tobago · ${periodEndLabel}`,\n ['Indicador', 'Valor'],\n resumenRows\n );\n\nconst valueData = [\n {\n range:\n `'${sheetTitles.nominaVsBanco}'!A1:H`,\n values: nominaVsBancoValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinNomina}'!A1:G`,\n values: bancoSinNominaValues,\n },\n {\n range:\n `'${sheetTitles.bancoSinBamboo}'!A1:F`,\n values: bancoSinBambooValues,\n },\n {\n range:\n `'${sheetTitles.diferenciasNombreBanco}'!A1:G`,\n values: diferenciasNombreValues,\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n range:\n `'${sheetTitles.cuentaMalDigitada}'!A1:C`,\n values:\n cuentaMalDigitadaValues,\n },\n ]\n : []),\n {\n range:\n `'${sheetTitles.resumen}'!A1:B`,\n values: resumenValues,\n },\n];\n\nconst brandColor = {\n red: 0.29,\n green: 0.49,\n blue: 0.58,\n};\n\nconst whiteColor = {\n red: 1,\n green: 1,\n blue: 1,\n};\n\nconst borderColor = {\n red: 0.82,\n green: 0.86,\n blue: 0.88,\n};\n\nfunction mergeRow(\n sheetId,\n rowIndex,\n columnCount\n) {\n return {\n mergeCells: {\n range: {\n sheetId,\n startRowIndex: rowIndex,\n endRowIndex: rowIndex + 1,\n startColumnIndex: 0,\n endColumnIndex: columnCount,\n },\n mergeType: 'MERGE_ALL',\n },\n };\n}\n\nfunction formatRange(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n userEnteredFormat\n) {\n const formatFields =\n Object.keys(userEnteredFormat || {});\n\n return {\n repeatCell: {\n range: {\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n },\n cell: {\n userEnteredFormat,\n },\n fields:\n `userEnteredFormat(${formatFields.join(',')})`,\n },\n };\n}\n\nfunction titleFormat(\n sheetId,\n rowIndex,\n columnCount,\n options = {}\n) {\n return formatRange(\n sheetId,\n rowIndex,\n rowIndex + 1,\n 0,\n columnCount,\n {\n backgroundColor: brandColor,\n textFormat: {\n bold: options.bold ?? true,\n italic:\n options.italic ?? false,\n fontSize:\n options.fontSize ?? 12,\n foregroundColor:\n whiteColor,\n },\n horizontalAlignment: 'LEFT',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction headerFormat(\n sheetId,\n columnCount\n) {\n return formatRange(\n sheetId,\n 3,\n 4,\n 0,\n columnCount,\n {\n backgroundColor: brandColor,\n textFormat: {\n bold: true,\n foregroundColor:\n whiteColor,\n },\n horizontalAlignment: 'CENTER',\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction freezeRows(\n sheetId,\n count\n) {\n return {\n updateSheetProperties: {\n properties: {\n sheetId,\n gridProperties: {\n frozenRowCount: count,\n },\n },\n fields:\n 'gridProperties.frozenRowCount',\n },\n };\n}\n\nfunction setFilter(\n sheetId,\n columnCount,\n endRowIndex\n) {\n return {\n setBasicFilter: {\n filter: {\n range: {\n sheetId,\n startRowIndex: 3,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex:\n columnCount,\n },\n },\n },\n };\n}\n\nfunction setColumnWidth(\n sheetId,\n index,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'COLUMNS',\n startIndex: index,\n endIndex: index + 1,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction setRowHeight(\n sheetId,\n startIndex,\n endIndex,\n pixelSize\n) {\n return {\n updateDimensionProperties: {\n range: {\n sheetId,\n dimension: 'ROWS',\n startIndex,\n endIndex,\n },\n properties: {\n pixelSize,\n },\n fields: 'pixelSize',\n },\n };\n}\n\nfunction borderFormat(\n sheetId,\n columnCount,\n endRowIndex\n) {\n const border = {\n style: 'SOLID',\n color: borderColor,\n };\n\n return [\n formatRange(\n sheetId,\n 3,\n endRowIndex,\n 0,\n columnCount,\n {\n verticalAlignment: 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n {\n updateBorders: {\n range: {\n sheetId,\n startRowIndex: 3,\n endRowIndex,\n startColumnIndex: 0,\n endColumnIndex: columnCount,\n },\n top: border,\n bottom: border,\n left: border,\n right: border,\n innerHorizontal: border,\n innerVertical: border,\n },\n },\n ];\n}\n\nfunction moneyFormat(\n sheetId,\n startColumnIndex,\n endColumnIndex,\n startRowIndex,\n endRowIndex\n) {\n return formatRange(\n sheetId,\n startRowIndex,\n endRowIndex,\n startColumnIndex,\n endColumnIndex,\n {\n numberFormat: {\n type: 'NUMBER',\n pattern:\n '\"TT$\"#,##0.00',\n },\n horizontalAlignment:\n 'RIGHT',\n verticalAlignment:\n 'MIDDLE',\n }\n );\n}\n\nfunction statusFormat(\n sheetId,\n columnIndex,\n endRowIndex\n) {\n return formatRange(\n sheetId,\n 4,\n endRowIndex,\n columnIndex,\n columnIndex + 1,\n {\n backgroundColor: {\n red: 1,\n green: 0.92,\n blue: 0.92,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.82,\n green: 0.08,\n blue: 0.08,\n },\n },\n horizontalAlignment:\n 'CENTER',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n );\n}\n\nfunction conditionalDifference(\n sheetId,\n endRowIndex,\n formula,\n backgroundColor,\n textColor\n) {\n return {\n addConditionalFormatRule: {\n rule: {\n ranges: [\n {\n sheetId,\n startRowIndex: 4,\n endRowIndex,\n startColumnIndex: 5,\n endColumnIndex: 6,\n },\n ],\n booleanRule: {\n condition: {\n type: 'CUSTOM_FORMULA',\n values: [\n {\n userEnteredValue:\n formula,\n },\n ],\n },\n format: {\n backgroundColor,\n textFormat: {\n bold: true,\n foregroundColor:\n textColor,\n },\n },\n },\n },\n index: 0,\n },\n };\n}\n\nfunction styleReport(config) {\n const {\n sheetId,\n columnCount,\n bodyRowsCount,\n widths,\n moneyColumns = [],\n statusColumn = null,\n } = config;\n\n const endRowIndex = Math.max(\n 4 + bodyRowsCount,\n 4\n );\n\n const requests = [\n mergeRow(\n sheetId,\n 0,\n columnCount\n ),\n mergeRow(\n sheetId,\n 1,\n columnCount\n ),\n titleFormat(\n sheetId,\n 0,\n columnCount,\n {\n fontSize: 12,\n bold: true,\n }\n ),\n titleFormat(\n sheetId,\n 1,\n columnCount,\n {\n fontSize: 10,\n bold: false,\n italic: true,\n }\n ),\n headerFormat(\n sheetId,\n columnCount\n ),\n freezeRows(sheetId, 4),\n setFilter(\n sheetId,\n columnCount,\n endRowIndex\n ),\n ...borderFormat(\n sheetId,\n columnCount,\n endRowIndex\n ),\n setRowHeight(\n sheetId,\n 0,\n 1,\n 30\n ),\n setRowHeight(\n sheetId,\n 1,\n 2,\n 28\n ),\n setRowHeight(\n sheetId,\n 3,\n 4,\n 42\n ),\n ...widths.map(\n (width, index) =>\n setColumnWidth(\n sheetId,\n index,\n width\n )\n ),\n ];\n\n if (bodyRowsCount > 0) {\n requests.push(\n setRowHeight(\n sheetId,\n 4,\n endRowIndex,\n 30\n )\n );\n\n for (\n const [startColumn, endColumn] of\n moneyColumns\n ) {\n requests.push(\n moneyFormat(\n sheetId,\n startColumn,\n endColumn,\n 4,\n endRowIndex\n )\n );\n }\n\n if (\n Number.isInteger(\n statusColumn\n )\n ) {\n requests.push(\n statusFormat(\n sheetId,\n statusColumn,\n endRowIndex\n )\n );\n }\n }\n\n return requests;\n}\n\nconst formatRequests = [\n ...styleReport({\n sheetId:\n sheetIds.nominaVsBanco,\n columnCount: 8,\n bodyRowsCount:\n mainRows.length,\n widths: [\n 48,\n 250,\n 145,\n 135,\n 135,\n 135,\n 180,\n 120,\n ],\n moneyColumns: [\n [3, 6],\n ],\n statusColumn: 7,\n }),\n\n ...(mainRows.length > 0\n ? [\n conditionalDifference(\n sheetIds.nominaVsBanco,\n 4 + mainRows.length,\n '=$F5>0',\n {\n red: 1,\n green: 0.97,\n blue: 0.82,\n },\n {\n red: 0.45,\n green: 0.27,\n blue: 0,\n }\n ),\n conditionalDifference(\n sheetIds.nominaVsBanco,\n 4 + mainRows.length,\n '=$F5<0',\n {\n red: 1,\n green: 0.89,\n blue: 0.89,\n },\n {\n red: 0.85,\n green: 0.05,\n blue: 0.05,\n }\n ),\n ]\n : []),\n\n ...styleReport({\n sheetId:\n sheetIds.bancoSinNomina,\n columnCount: 7,\n bodyRowsCount:\n bancoSinNominaRows.length,\n widths: [\n 48,\n 230,\n 145,\n 135,\n 230,\n 140,\n 360,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.bancoSinBamboo,\n columnCount: 6,\n bodyRowsCount:\n bancoSinBambooRows.length,\n widths: [\n 48,\n 250,\n 145,\n 140,\n 250,\n 150,\n ],\n moneyColumns: [[3, 4]],\n statusColumn: 5,\n }),\n\n ...styleReport({\n sheetId:\n sheetIds.diferenciasNombreBanco,\n columnCount: 7,\n bodyRowsCount:\n diferenciasNombreRows.length,\n widths: [\n 48,\n 240,\n 240,\n 145,\n 140,\n 150,\n 420,\n ],\n moneyColumns: [[4, 5]],\n statusColumn: 5,\n }),\n];\n\nif (hasCuentaMalDigitada) {\n const endRowIndex =\n 4 +\n cuentaMalDigitadaRows.length;\n\n formatRequests.push(\n ...styleReport({\n sheetId:\n sheetIds.cuentaMalDigitada,\n columnCount: 3,\n bodyRowsCount:\n cuentaMalDigitadaRows.length,\n widths: [\n 48,\n 300,\n 520,\n ],\n statusColumn: null,\n })\n );\n\n cuentaMalDigitadaCases.forEach(\n (_, caseIndex) => {\n const startRowIndex =\n 4 + caseIndex * 6;\n\n const endCaseRowIndex =\n startRowIndex + 6;\n\n formatRequests.push(\n {\n mergeCells: {\n range: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endRowIndex:\n endCaseRowIndex,\n startColumnIndex: 0,\n endColumnIndex: 1,\n },\n mergeType:\n 'MERGE_ALL',\n },\n },\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 0,\n 1,\n {\n backgroundColor: {\n red: 0.91,\n green: 0.95,\n blue: 0.99,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment:\n 'CENTER',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 1,\n 2,\n {\n backgroundColor: {\n red: 0.93,\n green: 0.97,\n blue: 0.90,\n },\n textFormat: {\n bold: true,\n foregroundColor: {\n red: 0.20,\n green: 0.36,\n blue: 0.45,\n },\n },\n horizontalAlignment:\n 'LEFT',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n formatRange(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 2,\n 3,\n {\n horizontalAlignment:\n 'LEFT',\n verticalAlignment:\n 'MIDDLE',\n wrapStrategy: 'WRAP',\n }\n ),\n setRowHeight(\n sheetIds.cuentaMalDigitada,\n startRowIndex,\n endCaseRowIndex,\n 52\n )\n );\n }\n );\n}\n\nconst resumenEndRowIndex =\n 4 + resumenRows.length;\n\nformatRequests.push(\n ...styleReport({\n sheetId:\n sheetIds.resumen,\n columnCount: 2,\n bodyRowsCount:\n resumenRows.length,\n widths: [340, 180],\n statusColumn: null,\n }),\n moneyFormat(\n sheetIds.resumen,\n 1,\n 2,\n resumenEndRowIndex - 3,\n resumenEndRowIndex\n )\n);\n\nreturn [\n {\n json: {\n ok: true,\n stage:\n 'preparar_google_sheet_tt',\n metadata,\n summary,\n spreadsheetTitle,\n sheetIds,\n sheetTitles,\n createSpreadsheetBody: {\n properties: {\n title: spreadsheetTitle,\n },\n sheets: [\n {\n properties: {\n sheetId:\n sheetIds.nominaVsBanco,\n title:\n sheetTitles.nominaVsBanco,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.bancoSinNomina,\n title:\n sheetTitles.bancoSinNomina,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.bancoSinBamboo,\n title:\n sheetTitles.bancoSinBamboo,\n },\n },\n {\n properties: {\n sheetId:\n sheetIds.diferenciasNombreBanco,\n title:\n sheetTitles.diferenciasNombreBanco,\n },\n },\n ...(hasCuentaMalDigitada\n ? [\n {\n properties: {\n sheetId:\n sheetIds.cuentaMalDigitada,\n title:\n sheetTitles.cuentaMalDigitada,\n },\n },\n ]\n : []),\n {\n properties: {\n sheetId:\n sheetIds.resumen,\n title:\n sheetTitles.resumen,\n },\n },\n ],\n },\n valueBatchBody: {\n valueInputOption:\n 'RAW',\n data: valueData,\n },\n formatBatchBody: {\n requests: formatRequests,\n },\n originalResponse: data,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1872,
1376
],
"id": "46cce451-8fb6-4deb-bca3-b8cd8a7711ef",
"name": "Preparar Google Sheet"
},
{
"parameters": {
"method": "POST",
"url": "https://sheets.googleapis.com/v4/spreadsheets",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n(() => {\n const prepared =\n $('Preparar Google Sheet').first().json || {};\n\n const createBody =\n prepared.createSpreadsheetBody || {};\n\n if (\n !Array.isArray(createBody.sheets) ||\n createBody.sheets.length === 0\n ) {\n throw new Error(\n 'Preparar Google Sheet no devolvió las hojas que deben crearse.'\n );\n }\n\n return {\n properties: {\n ...(createBody.properties || {}),\n timeZone: 'America/Port_of_Spain',\n },\n\n sheets: createBody.sheets.map((sheet) => ({\n properties: {\n ...(sheet.properties || {}),\n\n gridProperties: {\n ...((sheet.properties || {}).gridProperties || {}),\n frozenRowCount: 1,\n },\n },\n })),\n };\n})()\n}}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2128,
1376
],
"id": "79de4fa4-4d0f-45f6-af0f-6b1acb494cf0",
"name": "Crear Google Sheet",
"credentials": {
"httpBasicAuth": {
"id": "nIxZ7elcHvuzsRKW",
"name": "Neo4j"
},
"googleOAuth2Api": {
"id": "eHseMeH39kRcXgOF",
"name": "Google account 2"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Crear Google Sheet').first().json.spreadsheetId + '/values:batchUpdate' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $('Preparar Google Sheet').first().json.valueBatchBody }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2384,
1376
],
"id": "6c5feb7b-b33a-4488-8231-e31622d67a30",
"name": "Escribir Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Crear Google Sheet').first().json.spreadsheetId + ':batchUpdate' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $('Preparar Google Sheet').first().json.formatBatchBody }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
2640,
1376
],
"id": "41316f8a-f4fa-4b5f-852b-bdccfcfbdb7b",
"name": "Formatear Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"jsCode": "const createdSheet = $('Crear Google Sheet').first().json || {};\nconst spreadsheetId = createdSheet.spreadsheetId;\n\nif (!spreadsheetId) {\n throw new Error('No se recibió spreadsheetId desde Crear Google Sheet.');\n}\n\nconst allowedEmails = [\n 'iaracena@gomezleemarketing.com',\n 'ymadera@gomezleemarketing.com',\n 'mgomez@gomezleemarketing.com',\n 'jgomez@gomezleemarketing.com',\n];\n\nreturn allowedEmails.map((email) => ({\n json: {\n spreadsheetId,\n email,\n permissionBody: {\n type: 'user',\n role: 'writer',\n emailAddress: email,\n },\n },\n}));"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2912,
1376
],
"id": "b7581ad4-9305-4757-bb96-5716f68fd6d3",
"name": "Preparar permisos Google Sheet"
},
{
"parameters": {
"method": "POST",
"url": "={{ 'https://www.googleapis.com/drive/v3/files/' + $json.spreadsheetId + '/permissions?sendNotificationEmail=false' }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleOAuth2Api",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.permissionBody }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3168,
1376
],
"id": "a3ceb103-f683-48e5-8f71-c952c6d44626",
"name": "Compartir Google Sheet",
"credentials": {
"googleOAuth2Api": {
"id": "dQ1MJSJSWcoWYcb8",
"name": "Google account - Isaac Producción"
}
}
},
{
"parameters": {
"jsCode": "const cruce =\n $('Cruzar Nómina vs Banco').first().json || {};\n\nconst createdSheet =\n $('Crear Google Sheet').first().json || {};\n\nconst metadata = cruce.metadata || {};\nconst summary = cruce.summary || {};\nconst debug = cruce.debug || {};\n\nconst spreadsheetId =\n createdSheet.spreadsheetId ||\n cruce.spreadsheetId ||\n '';\n\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n createdSheet.spreadsheet_url ||\n (\n spreadsheetId\n ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`\n : null\n );\n\nfunction toNumber(value) {\n const parsed = Number(value);\n return Number.isFinite(parsed)\n ? parsed\n : 0;\n}\n\nfunction buildPeriodKey(periodMetadata) {\n const country =\n periodMetadata.country || 'TT';\n\n const year =\n periodMetadata.year || '';\n\n const month = String(\n periodMetadata.month || ''\n ).padStart(2, '0');\n\n const periodType =\n periodMetadata.period_type ||\n 'periodo';\n\n return (\n `${country}-${year}-${month}-${periodType}`\n );\n}\n\nconst discrepancias =\n toNumber(summary.discrepancias);\n\nconst discrepanciasMontoPago =\n toNumber(\n summary.discrepanciasMontoPago ??\n Math.max(\n 0,\n discrepancias -\n toNumber(\n summary.posiblesCuentasMalDigitadas\n )\n )\n );\n\nconst bancoSinNomina =\n toNumber(summary.bancoSinNomina);\n\nconst nominaSinCuenta =\n toNumber(summary.nominaSinCuenta);\n\nconst diferenciasNombreBanco =\n toNumber(\n summary.diferenciasNombreBanco\n );\n\nconst bancoSinBamboo =\n toNumber(summary.bancoSinBamboo);\n\nconst posiblesCuentasMalDigitadas =\n toNumber(\n summary.posiblesCuentasMalDigitadas\n );\n\nconst pendientes =\n toNumber(summary.pendientes) ||\n (\n discrepanciasMontoPago +\n bancoSinNomina +\n nominaSinCuenta +\n posiblesCuentasMalDigitadas +\n bancoSinBamboo +\n diferenciasNombreBanco\n );\n\nconst requiereRevision =\n pendientes > 0 ||\n bancoSinBamboo > 0;\n\nconst estado = requiereRevision\n ? 'pendiente_revision'\n : 'resuelto';\n\nconst payload = {\n source_app:\n metadata.source_app ||\n 'cruce-cuentas-glm-trinidad-tobago',\n\n country: 'TT',\n country_name:\n 'Trinidad y Tobago',\n\n year: toNumber(metadata.year),\n month: toNumber(metadata.month),\n period_type:\n metadata.period_type || '',\n period_label:\n metadata.period_label || '',\n period_start:\n metadata.period_start || null,\n period_end:\n metadata.period_end || null,\n period_key:\n buildPeriodKey({\n ...metadata,\n country: 'TT',\n }),\n\n payroll_file_name:\n metadata.payroll_file_name || '',\n\n bank_file_names:\n metadata.bank_file_names || [],\n\n coincidencias:\n toNumber(summary.coincidencias),\n\n discrepancias,\n\n banco_sin_bamboo:\n bancoSinBamboo,\n\n detalle_banco_sin_bamboo:\n Array.isArray(\n cruce.bankWithoutBamboo\n )\n ? cruce.bankWithoutBamboo\n : [],\n\n banco_sin_nomina:\n bancoSinNomina,\n\n nomina_sin_cuenta:\n nominaSinCuenta,\n\n nomina_sin_bamboo: 0,\n bamboo_sin_nomina: 0,\n\n filas_nomina_validas:\n toNumber(\n summary.filasNominaValidas\n ),\n\n cuentas_nomina_agrupadas:\n toNumber(\n summary.cuentasNominaAgrupadas\n ),\n\n transacciones_banco:\n toNumber(\n summary.transaccionesBanco\n ),\n\n cuentas_banco_agrupadas:\n toNumber(\n summary.cuentasBancoAgrupadas\n ),\n\n total_nomina:\n toNumber(summary.totalNomina),\n\n total_banco:\n toNumber(summary.totalBanco),\n\n diferencia_total:\n toNumber(\n summary.diferenciaTotal\n ),\n\n report_url: reportUrl,\n spreadsheet_id:\n spreadsheetId,\n estado,\n\n ejecutado_por_nombre:\n metadata.requested_by_name ||\n 'Usuario GLM',\n\n ejecutado_por_email:\n metadata.requested_by_email ||\n '',\n\n metadata: {\n ...metadata,\n country: 'TT',\n country_name:\n 'Trinidad y Tobago',\n diferencias_nombre_banco:\n diferenciasNombreBanco,\n banco_sin_bamboo:\n bancoSinBamboo,\n posibles_cuentas_mal_digitadas:\n toNumber(\n summary\n .posiblesCuentasMalDigitadas\n ),\n pendientes_cruce_principal:\n pendientes,\n requiere_revision:\n requiereRevision,\n },\n\n summary,\n\n debug: {\n sheet_summaries:\n debug.sheet_summaries || [],\n bank_name_differences:\n cruce.nameDifferences || [],\n bamboo_matches:\n debug.bamboo_matches || [],\n bamboo_excluded_payments:\n debug.bamboo_excluded_payments || [],\n banco_sin_bamboo:\n cruce.bankWithoutBamboo || [],\n },\n};\n\nreturn [\n {\n json: {\n ...cruce,\n\n // Se conserva la tabla histórica actual para\n // que la app pueda consultar todos los países\n // mediante el campo country y luego usar RPC.\n supabaseTable:\n 'cruces_cuentas_gt_reportes',\n\n supabasePayload: payload,\n reportUrl,\n spreadsheetId,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3424,
1376
],
"id": "2a352284-56df-4be7-bf28-e58d5aa59494",
"name": "Preparar histórico Supabase"
},
{
"parameters": {
"method": "POST",
"url": "https://dbit.digitalcompass.agency/rest/v1/cruces_cuentas_gt_reportes",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Authorization",
"value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Prefer",
"value": "return=representation"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.supabasePayload }}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
3680,
1376
],
"id": "ad36607d-a0d7-43e6-a261-aa1d5fd99b85",
"name": "Insertar histórico Supabase",
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "const prepared = $('Preparar Google Sheet').first().json || {};\nconst createdSheet = $('Crear Google Sheet').first().json || {};\n\nconst original =\n prepared.originalResponse ||\n prepared.original_response ||\n prepared.response ||\n {};\n\nconst spreadsheetId = createdSheet.spreadsheetId || '';\nconst reportUrl =\n createdSheet.spreadsheetUrl ||\n (spreadsheetId ? `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit` : null);\n\nreturn [\n {\n json: {\n ok: original.ok ?? true,\n message: reportUrl\n ? 'Cruce procesado correctamente. Google Sheet generado.'\n : 'Cruce procesado correctamente, pero no se recibió URL del Google Sheet.',\n stage: reportUrl ? 'cruce_completado_con_reporte' : 'cruce_completado_sin_reporte',\n errors: original.errors || [],\n metadata: original.metadata || {},\n summary: original.summary || {},\n rows: original.rows || [],\n bankWithoutBamboo:\n original.bankWithoutBamboo || [],\n bambooSummary:\n original.bambooSummary || {},\n reportUrl,\n googleSheet: {\n spreadsheetId,\n spreadsheetUrl: reportUrl,\n },\n debug: {\n rows_returned: Array.isArray(original.rows) ? original.rows.length : 0,\n coincidencias: original.summary?.coincidencias ?? 0,\n discrepancias: original.summary?.discrepancias ?? 0,\n discrepanciasMontoPago:\n original.summary?.discrepanciasMontoPago ?? 0,\n posiblesCuentasMalDigitadas:\n original.summary?.posiblesCuentasMalDigitadas ?? 0,\n totalResultados:\n original.summary?.totalResultados ?? 0,\n bancoSinBamboo:\n original.summary?.bancoSinBamboo ?? 0,\n bancoSinBambooRows:\n Array.isArray(original.bankWithoutBamboo)\n ? original.bankWithoutBamboo.length\n : 0,\n },\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3952,
1376
],
"id": "bc8c0691-dfe8-4980-9da9-022728ee77d1",
"name": "Preparar respuesta final"
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{\n(() => {\n const data = $json || {};\n\n const original =\n data.originalResponse ||\n data.original_response ||\n data.response ||\n data.cruceResponse ||\n data.cruce_response ||\n data;\n\n const summary = original.summary || data.summary || {};\n const rows = original.rows || data.rows || [];\n const bankWithoutBamboo =\n original.bankWithoutBamboo ||\n data.bankWithoutBamboo ||\n [];\n const bambooSummary =\n original.bambooSummary ||\n data.bambooSummary ||\n {};\n\n const reportUrl =\n data.reportUrl ||\n data.report_url ||\n data.googleSheetUrl ||\n data.google_sheet_url ||\n data.spreadsheetUrl ||\n data.spreadsheet_url ||\n original.reportUrl ||\n original.report_url ||\n null;\n\n return {\n ok: original.ok ?? data.ok ?? true,\n message: reportUrl\n ? 'Cruce procesado correctamente. Google Sheet generado.'\n : 'Cruce procesado correctamente.',\n stage: reportUrl ? 'cruce_completado_con_reporte' : 'cruce_completado',\n errors: original.errors || data.errors || [],\n metadata: original.metadata || data.metadata || {},\n summary,\n rows,\n bankWithoutBamboo,\n bambooSummary,\n reportUrl,\n debug: {\n source_stage: data.stage || null,\n rows_returned:\n Array.isArray(rows) ? rows.length : 0,\n banco_sin_bamboo_rows:\n Array.isArray(bankWithoutBamboo)\n ? bankWithoutBamboo.length\n : 0,\n report_url_found: Boolean(reportUrl),\n },\n };\n})()\n}}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
4208,
1376
],
"id": "7aac07dc-6bb1-45ec-9784-0242de3a3a9d",
"name": "Respond to Webhook"
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Preparar entrada app",
"type": "main",
"index": 0
}
]
]
},
"Preparar entrada app": {
"main": [
[
{
"node": "Parsear CSV banco TT",
"type": "main",
"index": 0
},
{
"node": "Extract - BICE",
"type": "main",
"index": 0
},
{
"node": "Extract - Goldey Samuel",
"type": "main",
"index": 0
},
{
"node": "Extract - P&G",
"type": "main",
"index": 0
},
{
"node": "Extract - Whirlpool",
"type": "main",
"index": 0
},
{
"node": "Extract - KAD",
"type": "main",
"index": 0
},
{
"node": "Extract - GLM People",
"type": "main",
"index": 0
},
{
"node": "Extract - GLM",
"type": "main",
"index": 0
}
]
]
},
"Extract - BICE": {
"main": [
[
{
"node": "Merge Hojas TT 01-02",
"type": "main",
"index": 0
}
]
]
},
"Extract - Goldey Samuel": {
"main": [
[
{
"node": "Merge Hojas TT 01-02",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 01-02": {
"main": [
[
{
"node": "Merge Hojas TT 03",
"type": "main",
"index": 0
}
]
]
},
"Extract - P&G": {
"main": [
[
{
"node": "Merge Hojas TT 03",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 03": {
"main": [
[
{
"node": "Merge Hojas TT 04",
"type": "main",
"index": 0
}
]
]
},
"Extract - Whirlpool": {
"main": [
[
{
"node": "Merge Hojas TT 04",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 04": {
"main": [
[
{
"node": "Merge Hojas TT 05",
"type": "main",
"index": 0
}
]
]
},
"Extract - KAD": {
"main": [
[
{
"node": "Merge Hojas TT 05",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 05": {
"main": [
[
{
"node": "Merge Hojas TT 06",
"type": "main",
"index": 0
}
]
]
},
"Extract - GLM People": {
"main": [
[
{
"node": "Merge Hojas TT 06",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 06": {
"main": [
[
{
"node": "Merge Hojas TT 07",
"type": "main",
"index": 0
}
]
]
},
"Extract - GLM": {
"main": [
[
{
"node": "Merge Hojas TT 07",
"type": "main",
"index": 1
}
]
]
},
"Merge Hojas TT 07": {
"main": [
[
{
"node": "Normalizar Nómina TT",
"type": "main",
"index": 0
}
]
]
},
"Parsear CSV banco TT": {
"main": [
[
{
"node": "Merge Banco + Nómina TT",
"type": "main",
"index": 0
}
]
]
},
"Normalizar Nómina TT": {
"main": [
[
{
"node": "Merge Banco + Nómina TT",
"type": "main",
"index": 1
}
]
]
},
"HTTP - Empleados BambooHR TT": {
"main": [
[
{
"node": "Normalizar BambooHR TT",
"type": "main",
"index": 0
}
]
]
},
"Merge Banco + Nómina TT": {
"main": [
[
{
"node": "HTTP - Empleados BambooHR TT",
"type": "main",
"index": 0
},
{
"node": "Merge - Agregar BambooHR TT",
"type": "main",
"index": 0
}
]
]
},
"Normalizar BambooHR TT": {
"main": [
[
{
"node": "Merge - Agregar BambooHR TT",
"type": "main",
"index": 1
}
]
]
},
"Merge - Agregar BambooHR TT": {
"main": [
[
{
"node": "Cruzar Nómina vs Banco",
"type": "main",
"index": 0
}
]
]
},
"Cruzar Nómina vs Banco": {
"main": [
[
{
"node": "Preparar Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Preparar Google Sheet": {
"main": [
[
{
"node": "Crear Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Crear Google Sheet": {
"main": [
[
{
"node": "Escribir Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Escribir Google Sheet": {
"main": [
[
{
"node": "Formatear Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Formatear Google Sheet": {
"main": [
[
{
"node": "Preparar permisos Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Preparar permisos Google Sheet": {
"main": [
[
{
"node": "Compartir Google Sheet",
"type": "main",
"index": 0
}
]
]
},
"Compartir Google Sheet": {
"main": [
[
{
"node": "Preparar histórico Supabase",
"type": "main",
"index": 0
}
]
]
},
"Preparar histórico Supabase": {
"main": [
[
{
"node": "Insertar histórico Supabase",
"type": "main",
"index": 0
}
]
]
},
"Insertar histórico Supabase": {
"main": [
[
{
"node": "Preparar respuesta final",
"type": "main",
"index": 0
}
]
]
},
"Preparar respuesta final": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"authors": "Isaac Aracena",
"name": "Version 8d7fee0e",
"description": "",
"autosaved": true,
"workflowPublishHistory": [
{
"createdAt": "2026-07-14T17:21:03.796Z",
"id": 2921,
"workflowId": "5AujMxduslftVg9z",
"versionId": "8d7fee0e-1568-42f6-b897-795c78f921ca",
"event": "activated",
"userId": "0a88c0b1-928e-4412-896e-c5d1c99b2029"
}
]
}
}