feat: implement phase 5 settlement calculation and approvals

This commit is contained in:
Luis Gabriel Ramos Robles 2026-06-12 15:18:40 +00:00
parent 24d54f8bed
commit a3a88d11a7
31 changed files with 3406 additions and 235 deletions

View file

@ -0,0 +1,442 @@
{
"name": "Settlement Calculation Workflow",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "calculate-settlements",
"responseMode": "responseNode",
"options": {
"rawBody": false
}
},
"id": "Webhook-Node-1",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [
100,
300
]
},
{
"parameters": {
"conditions": {
"string": [
{
"value1": "={{ $node[\"Webhook\"].json[\"headers\"][\"x-forwarded-uri\"] || \"\" }}",
"operation": "contains",
"value2": "webhook-test"
}
]
}
},
"id": "If-Node-1",
"name": "Is Test/Dev Mode?",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
300,
300
]
},
{
"parameters": {
"values": {
"string": [
{
"name": "appUrl",
"value": "https://special-hotel-dev.gaboggamer.online"
},
{
"name": "signature",
"value": "qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc="
}
]
},
"options": {}
},
"id": "Set-Dev-Env",
"name": "Set Dev Environment",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [
500,
200
]
},
{
"parameters": {
"values": {
"string": [
{
"name": "appUrl",
"value": "https://special-hotel.gaboggamer.online"
},
{
"name": "signature",
"value": "Ecjb2s33tHJppNBDJ/DxXEjHWKow8bNWmsQrk1sQKyQ="
}
]
},
"options": {}
},
"id": "Set-Prod-Env",
"name": "Set Prod Environment",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [
500,
400
]
},
{
"parameters": {
"method": "GET",
"url": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.appUrl : $('Set Prod Environment').item.json.appUrl }}/api/n8n/fetch-calculation-data",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "x-n8n-signature",
"value": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.signature : $('Set Prod Environment').item.json.signature }}"
}
]
},
"sendQueryParameters": true,
"queryParameters": {
"parameters": [
{
"name": "period",
"value": "={{ $node[\"Webhook\"].json[\"body\"][\"period\"] }}"
}
]
},
"options": {}
},
"id": "Http-Request-Fetch",
"name": "Fetch Calculation Data",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
700,
300
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.appUrl : $('Set Prod Environment').item.json.appUrl }}/api/n8n/process-formula",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "x-n8n-signature",
"value": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.signature : $('Set Prod Environment').item.json.signature }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"period\": \"{{ $node[\"Webhook\"].json[\"body\"][\"period\"] }}\",\n \"simulateOnly\": {{ $node[\"Webhook\"].json[\"body\"][\"simulateOnly\"] }},\n \"collaborators\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"collaborators\"]) }},\n \"activePlans\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"activePlans\"]) }},\n \"goals\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"goals\"]) }},\n \"salesResults\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"salesResults\"]) }},\n \"pastPeriods\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"pastPeriods\"]) }},\n \"approvedPastSettlements\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"approvedPastSettlements\"]) }},\n \"pastSalesResults\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"pastSalesResults\"]) }},\n \"pastGoals\": {{ JSON.stringify($node[\"Fetch Calculation Data\"].json[\"pastGoals\"]) }}\n}",
"options": {}
},
"id": "Http-Request-Process",
"name": "Process Formula",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
900,
300
]
},
{
"parameters": {
"promptType": "define",
"text": "=Review the calculated settlements for compliance. For each item in the results array, evaluate the main settlement totals and adjustments. Return a JSON object with a \"results\" array matching the input structure, but adding the fields \"aiAudited\" (boolean) and \"aiAuditNotes\" (string or null).\nIf a collaborator's total payout is negative, set aiAudited to true and aiAuditNotes to \"Audit warning: Negative net payout detected due to clawback adjustments.\"\nIf a collaborator's total payout exceeds 10,000, set aiAudited to true and aiAuditNotes to \"Audit warning: High commission payout exceeding 10K threshold.\"\nOtherwise, set aiAudited to false and aiAuditNotes to null.\n\nInput calculations data:\n{{ JSON.stringify($node[\"Process Formula\"].json[\"results\"]) }}",
"hasOutputParser": true,
"needsFallback": true,
"options": {}
},
"id": "LLM-Chain-Audit",
"name": "LLM Chain: Compliance Audit",
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"typeVersion": 1.9,
"position": [
1100,
300
]
},
{
"parameters": {
"options": {}
},
"id": "DeepSeek-Model-Node-2",
"name": "Primary Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatDeepSeek",
"typeVersion": 1,
"position": [
950,
480
],
"credentials": {
"deepSeekApi": {
"id": "YbGKBhQ9rlUebE4F",
"name": "Semillero2_Primary_deepSeekApi"
}
}
},
{
"parameters": {
"options": {}
},
"id": "Google-Gemini-Model-Node-2",
"name": "Fallback Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
"typeVersion": 1,
"position": [
1100,
480
],
"credentials": {
"googlePalmApi": {
"id": "1Vz05qteUH9kLf7Z",
"name": "Google Gemini(PaLM) Api account"
}
}
},
{
"parameters": {
"jsonSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"results\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"main\": {\n \"type\": \"object\",\n \"properties\": {\n \"period\": { \"type\": \"string\" },\n \"planId\": { \"type\": \"number\" },\n \"userId\": { \"type\": \"number\" },\n \"salesAmount\": { \"type\": \"number\" },\n \"goalAmount\": { \"type\": \"number\" },\n \"achievementPercentage\": { \"type\": \"number\" },\n \"calculatedCommission\": { \"type\": \"number\" },\n \"calculatedBonus\": { \"type\": \"number\" },\n \"adjustmentAmount\": { \"type\": \"number\" },\n \"totalPayout\": { \"type\": \"number\" },\n \"status\": { \"type\": \"string\" },\n \"adjustmentNotes\": { \"type\": \"string\", \"nullable\": true }\n }\n },\n \"adjustments\": { \"type\": \"array\" },\n \"aiAudited\": { \"type\": \"boolean\" },\n \"aiAuditNotes\": { \"type\": \"string\", \"nullable\": true }\n }\n }\n }\n },\n \"required\": [\"results\"]\n}"
},
"id": "Structured-Output-Parser-Node-2",
"name": "Structured Output Parser",
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"typeVersion": 1,
"position": [
1250,
480
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $node[\"Webhook\"].json[\"body\"][\"simulateOnly\"] }}",
"value2": true
}
]
}
},
"id": "If-Node-Simulation",
"name": "Is Simulation Only?",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
1350,
300
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.appUrl : $('Set Prod Environment').item.json.appUrl }}/api/n8n/save-settlements",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "x-n8n-signature",
"value": "={{ $('Set Dev Environment').isExecuted ? $('Set Dev Environment').item.json.signature : $('Set Prod Environment').item.json.signature }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"period\": \"{{ $node[\"Webhook\"].json[\"body\"][\"period\"] }}\",\n \"simulateOnly\": false,\n \"results\": {{ JSON.stringify($node[\"LLM Chain: Compliance Audit\"].json[\"results\"]) }},\n \"uploaderId\": {{ $node[\"Webhook\"].json[\"body\"][\"uploaderId\"] || 1 }}\n}",
"options": {}
},
"id": "Http-Request-Save",
"name": "Save Settlements",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
1550,
400
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={\n \"success\": true,\n \"code\": \"SETTLEMENTS_CALCULATED\",\n \"metadata\": {\n \"count\": {{ $node[\"Process Formula\"].json[\"metadata\"][\"count\"] }},\n \"totalCommission\": {{ $node[\"Process Formula\"].json[\"metadata\"][\"totalCommission\"] }},\n \"totalAdjustment\": {{ $node[\"Process Formula\"].json[\"metadata\"][\"totalAdjustment\"] }}\n },\n \"simulated\": {{ JSON.stringify($node[\"LLM Chain: Compliance Audit\"].json[\"results\"]) }}\n}"
},
"id": "Respond-To-Webhook-Calculated",
"name": "Respond to Webhook (Calculated)",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
1750,
300
]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Is Test/Dev Mode?",
"type": "main",
"index": 0
}
]
]
},
"Is Test/Dev Mode?": {
"main": [
[
{
"node": "Set Dev Environment",
"type": "main",
"index": 0
}
],
[
{
"node": "Set Prod Environment",
"type": "main",
"index": 0
}
]
]
},
"Set Dev Environment": {
"main": [
[
{
"node": "Fetch Calculation Data",
"type": "main",
"index": 0
}
]
]
},
"Set Prod Environment": {
"main": [
[
{
"node": "Fetch Calculation Data",
"type": "main",
"index": 0
}
]
]
},
"Fetch Calculation Data": {
"main": [
[
{
"node": "Process Formula",
"type": "main",
"index": 0
}
]
]
},
"Process Formula": {
"main": [
[
{
"node": "LLM Chain: Compliance Audit",
"type": "main",
"index": 0
}
]
]
},
"Primary Chat Model": {
"ai_languageModel": [
[
{
"node": "LLM Chain: Compliance Audit",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Fallback Chat Model": {
"ai_languageModel": [
[
{
"node": "LLM Chain: Compliance Audit",
"type": "ai_languageModel",
"index": 1
}
]
]
},
"Structured Output Parser": {
"outputParser": [
[
{
"node": "LLM Chain: Compliance Audit",
"type": "ai_outputParser",
"index": 0
}
]
]
},
"LLM Chain: Compliance Audit": {
"main": [
[
{
"node": "Is Simulation Only?",
"type": "main",
"index": 0
}
]
]
},
"Is Simulation Only?": {
"main": [
[
{
"node": "Respond to Webhook (Calculated)",
"type": "main",
"index": 0
}
],
[
{
"node": "Save Settlements",
"type": "main",
"index": 0
}
]
]
},
"Save Settlements": {
"main": [
[
{
"node": "Respond to Webhook (Calculated)",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {},
"id": "settlement-calculation-workflow"
}

View file

@ -12,7 +12,8 @@
"n8n:bootstrap": "node scripts/n8n-bootstrap.js",
"test:rls": "node prisma/test-rls.js",
"test:auth-rls": "node prisma/test-auth-rls.js",
"test:ui": "next build && node prisma/test-phase3-ui.js && node prisma/test-phase4-ui.js",
"test:phase5": "node prisma/test-phase5-ui.js",
"test:ui": "next build && node prisma/test-phase3-ui.js && node prisma/test-phase4-ui.js && node prisma/test-phase5-ui.js",
"test:n8n": "node prisma/test-n8n-real.js",
"test:all": "pnpm run test:rls && pnpm run test:auth-rls && pnpm run test:ui && pnpm run test:n8n",
"test": "pnpm run test:all"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -10,7 +10,7 @@ const BASE_URL = "http://127.0.0.1:3001";
let prisma;
let pool;
let workflowId;
const deployedWorkflowIds = [];
let n8nUrl;
let n8nApiKey;
@ -61,11 +61,13 @@ async function cleanupDb() {
console.log("Cleaning up integration test records in test database...");
try {
await runAsAdmin(async (tx) => {
console.log("DEBUG: Available models on tx:", Object.keys(tx).filter(k => !k.startsWith('$')));
// Clean sales results
await tx.salesResult.deleteMany({
where: {
idempotencyKey: { startsWith: 'key-integration' }
OR: [
{ idempotencyKey: { startsWith: 'key-integration' } },
{ period: '2026-07' }
]
}
});
// Clean import jobs
@ -75,9 +77,26 @@ async function cleanupDb() {
idempotencyKey: { startsWith: 'key-integration' }
}
});
} else {
console.warn("WARNING: tx.salesImportJob is undefined, skipping deleteMany.");
}
// Clean settlements
await tx.settlement.deleteMany({
where: {
period: '2026-07'
}
});
// Clean goals
await tx.goal.deleteMany({
where: {
period: '2026-07'
}
});
// Clean test plans
await tx.calculationRule.deleteMany({
where: { plan: { code: 'PLAN-INT-P5' } }
});
await tx.compensationPlan.deleteMany({
where: { code: 'PLAN-INT-P5' }
});
// Clean audit logs
await tx.auditLog.deleteMany({
where: {
@ -114,30 +133,7 @@ async function runTests() {
process.exit(1);
}
// 3. Load and modify n8n workflow JSON to point to our local test server via Docker bridge
const workflowPath = path.join(__dirname, '../n8n/sales_import_workflow.json');
if (!fs.existsSync(workflowPath)) {
console.error("Error: n8n/sales_import_workflow.json not found.");
process.exit(1);
}
const workflowJson = JSON.parse(fs.readFileSync(workflowPath, 'utf8'));
const webhookPath = `calculate-commissions-${Date.now()}`;
// Point callbacks to dev server, set dev test signature, and randomize path to prevent webhook conflicts
for (const node of workflowJson.nodes) {
if (node.id === 'Set-Dev-Env' || node.id === 'Set-Prod-Env') {
node.parameters.values.string = [
{ name: 'appUrl', value: 'http://special-hotel-dev:3000' },
{ name: 'signature', value: process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=' }
];
}
if (node.type === 'n8n-nodes-base.webhook') {
node.parameters.path = webhookPath;
}
}
// 3.5. Fetch existing workflows to retrieve the project ID dynamically
// 3. Fetch existing workflows to retrieve the project ID dynamically
console.log("Fetching project ID from existing workflows...");
let projectId;
try {
@ -159,7 +155,7 @@ async function runTests() {
console.warn("Warning: Could not fetch project ID dynamically, falling back to default.", err.message);
}
// 3.6. Fetch credentials dynamically to resolve AI accounts
// 4. Fetch credentials dynamically to resolve AI accounts
console.log("Fetching credentials from n8n to resolve AI accounts...");
let deepSeekCred = null;
let geminiCred = null;
@ -183,89 +179,11 @@ async function runTests() {
if (geminiCreds.length > 0) {
geminiCred = geminiCreds.find(c => c.name.toLowerCase().includes('gemini') || c.name.toLowerCase().includes('google')) || geminiCreds[0];
}
if (deepSeekCred) {
console.log(`Resolved DeepSeek credential: "${deepSeekCred.name}" (ID: ${deepSeekCred.id})`);
}
if (geminiCred) {
console.log(`Resolved Gemini credential: "${geminiCred.name}" (ID: ${geminiCred.id})`);
}
} else {
console.warn(`Warning: Failed to fetch credentials. Status: ${credsRes.status}`);
}
} catch (err) {
console.warn("Warning: Could not fetch credentials dynamically:", err.message);
}
// Inject resolved credentials dynamically into the workflow JSON
for (const node of workflowJson.nodes) {
if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) {
node.credentials = {
deepSeekApi: {
id: deepSeekCred.id,
name: deepSeekCred.name
}
};
console.log(`Injected DeepSeek credential into node "${node.name}"`);
}
if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) {
node.credentials = {
googlePalmApi: {
id: geminiCred.id,
name: geminiCred.name
}
};
console.log(`Injected Gemini credential into node "${node.name}"`);
}
}
// 4. Create workflow in n8n
console.log("Deploying workflow to real n8n instance...");
const workflowName = `Semillero E2E Integration: Sales Import - ${Date.now()}`;
const createRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-N8N-API-KEY': n8nApiKey
},
body: JSON.stringify({
name: workflowName,
nodes: workflowJson.nodes,
connections: workflowJson.connections,
settings: workflowJson.settings || {},
projectId
})
});
if (!createRes.ok) {
const errorText = await createRes.text();
console.error(`Error: Failed to create n8n workflow. Status: ${createRes.status}. Output: ${errorText}`);
process.exit(1);
}
const createData = await createRes.json();
workflowId = createData.id;
console.log(`Workflow deployed successfully! ID: ${workflowId}. Full response:`, JSON.stringify(createData));
// Activate the workflow if it is inactive (normally setting active in POST is enough, but double-checking)
if (!createData.active) {
console.log("Activating workflow...");
const activateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}/activate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-N8N-API-KEY': n8nApiKey
},
body: JSON.stringify({})
});
if (!activateRes.ok) {
console.error("Failed to activate workflow:", await activateRes.text());
} else {
console.log("Workflow activated successfully! Waiting 5 seconds for n8n webhooks to register...");
await sleep(5000);
}
}
// 5. Verify the external dev server is running
console.log(`Verifying target dev server at ${BASE_URL}...`);
try {
@ -279,19 +197,74 @@ async function runTests() {
process.exit(1);
}
// 6. Generate test parameters
const idempotencyKey = `key-integration-${Date.now()}`;
// ==========================================
// TEST CASE 1: SALES IMPORT WORKFLOW
// ==========================================
console.log("\n[Test 1] Deploying and triggering Sales Import Workflow...");
const importWorkflowPath = path.join(__dirname, '../n8n/sales_import_workflow.json');
if (!fs.existsSync(importWorkflowPath)) {
console.error("Error: n8n/sales_import_workflow.json not found.");
process.exit(1);
}
// 7. Trigger the n8n webhook directly
console.log(`Triggering n8n webhook directly at ${n8nUrl}/webhook/${webhookPath}...`);
const uploadRes = await fetch(`${n8nUrl}/webhook/${webhookPath}`, {
const importWfJson = JSON.parse(fs.readFileSync(importWorkflowPath, 'utf8'));
const importWebhookPath = `calculate-commissions-${Date.now()}`;
// Configure dev app urls and webhooks
for (const node of importWfJson.nodes) {
if (node.id === 'Set-Dev-Env' || node.id === 'Set-Prod-Env') {
node.parameters.values.string = [
{ name: 'appUrl', value: 'http://special-hotel-dev:3000' },
{ name: 'signature', value: process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=' }
];
}
if (node.type === 'n8n-nodes-base.webhook') {
node.parameters.path = importWebhookPath;
}
if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) {
node.credentials = { deepSeekApi: { id: deepSeekCred.id, name: deepSeekCred.name } };
}
if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) {
node.credentials = { googlePalmApi: { id: geminiCred.id, name: geminiCred.name } };
}
}
const importWfName = `Semillero E2E Integration: Sales Import - ${Date.now()}`;
const importCreateRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-N8N-API-KEY': n8nApiKey },
body: JSON.stringify({
name: importWfName,
nodes: importWfJson.nodes,
connections: importWfJson.connections,
settings: importWfJson.settings || {},
projectId
})
});
if (!importCreateRes.ok) {
console.error("Failed to deploy import workflow:", await importCreateRes.text());
process.exit(1);
}
const importWfData = await importCreateRes.json();
deployedWorkflowIds.push(importWfData.id);
// Activate import workflow
await fetch(`${n8nUrl}/api/v1/workflows/${importWfData.id}/activate`, {
method: 'POST',
headers: { 'X-N8N-API-KEY': n8nApiKey }
});
await sleep(3000);
const importIdempotencyKey = `key-integration-import-${Date.now()}`;
const uploadRes = await fetch(`${n8nUrl}/webhook/${importWebhookPath}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc='
},
body: JSON.stringify({
idempotencyKey,
idempotencyKey: importIdempotencyKey,
uploaderId: 1,
sales: [
{
@ -300,38 +273,190 @@ async function runTests() {
period: '2026-06',
amount: 15000,
salesCount: 5,
transactionId: 'TX-EST-MDE-001'
transactionId: `TX-INT-COMM-${Date.now()}`
}
]
})
});
assert(uploadRes.status === 200 || uploadRes.status === 202, `n8n webhook response code is ${uploadRes.status} (expected 200/202)`);
assert(uploadRes.status === 200 || uploadRes.status === 202, `Import webhook response code: ${uploadRes.status}`);
const uploadData = await uploadRes.json();
assert(uploadData.success && uploadData.code === 'IMPORT_ACCEPTED', "n8n accepted the webhook trigger");
assert(uploadData.success && uploadData.code === 'IMPORT_ACCEPTED', "Import accepted by n8n");
// 8. Poll DB for record insert from n8n callback
console.log("Waiting for n8n to complete processing and send callback to Next.js...");
let callbackCompleted = false;
let importCallbackCompleted = false;
for (let i = 0; i < 20; i++) {
await sleep(2000);
const dbSales = await runAsAdmin(tx => tx.salesResult.findMany({
where: {
idempotencyKey: {
startsWith: idempotencyKey
}
}
where: { idempotencyKey: { startsWith: importIdempotencyKey } }
}));
if (dbSales.length > 0) {
assert(dbSales.length === 1, "1 sales result record successfully processed and saved via n8n integration!");
assert(parseFloat(dbSales[0].amount) === 15000.00, "Sales amount verified (15,000.00)");
callbackCompleted = true;
assert(dbSales.length === 1, "Sales result saved to dev database by n8n callback");
assert(parseFloat(dbSales[0].amount) === 15000.00, "Imported amount verified");
importCallbackCompleted = true;
break;
}
}
assert(importCallbackCompleted, "Sales import integration completed successfully");
assert(callbackCompleted, "n8n background execution and database write callback verified");
// ==========================================
// TEST CASE 2: SETTLEMENT CALCULATION WORKFLOW
// ==========================================
console.log("\n[Test 2] Deploying and triggering Settlement Calculation Workflow...");
const calcWorkflowPath = path.join(__dirname, '../n8n/settlement_calculation_workflow.json');
if (!fs.existsSync(calcWorkflowPath)) {
console.error("Error: n8n/settlement_calculation_workflow.json not found.");
await cleanup();
process.exit(1);
}
const calcWfJson = JSON.parse(fs.readFileSync(calcWorkflowPath, 'utf8'));
const calcWebhookPath = `calculate-settlements-${Date.now()}`;
// Configure dev app urls and webhooks
for (const node of calcWfJson.nodes) {
if (node.id === 'Set-Dev-Env' || node.id === 'Set-Prod-Env') {
node.parameters.values.string = [
{ name: 'appUrl', value: 'http://special-hotel-dev:3000' },
{ name: 'signature', value: process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=' }
];
}
if (node.type === 'n8n-nodes-base.webhook') {
node.parameters.path = calcWebhookPath;
}
if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) {
node.credentials = { deepSeekApi: { id: deepSeekCred.id, name: deepSeekCred.name } };
}
if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) {
node.credentials = { googlePalmApi: { id: geminiCred.id, name: geminiCred.name } };
}
}
const calcWfName = `Semillero E2E Integration: Settlement Calc - ${Date.now()}`;
const calcCreateRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-N8N-API-KEY': n8nApiKey },
body: JSON.stringify({
name: calcWfName,
nodes: calcWfJson.nodes,
connections: calcWfJson.connections,
settings: calcWfJson.settings || {},
projectId
})
});
if (!calcCreateRes.ok) {
console.error("Failed to deploy calculation workflow:", await calcCreateRes.text());
await cleanup();
process.exit(1);
}
const calcWfData = await calcCreateRes.json();
deployedWorkflowIds.push(calcWfData.id);
// Activate calculation workflow
await fetch(`${n8nUrl}/api/v1/workflows/${calcWfData.id}/activate`, {
method: 'POST',
headers: { 'X-N8N-API-KEY': n8nApiKey }
});
await sleep(3000);
// Seed DB records for the calculation E2E test
console.log("Seeding test database with plan, rules, goal, and sales results for calculation E2E test...");
const adminUser = (await runAsAdmin(tx => tx.user.findMany())).find(u => u.username === 'admin');
const colaboradorMde = (await runAsAdmin(tx => tx.user.findMany())).find(u => u.username === 'colaborador_mde');
const testPlan = await runAsAdmin(async (tx) => {
// Delete existing PLAN-INT-P5 if any
await tx.calculationRule.deleteMany({ where: { plan: { code: 'PLAN-INT-P5' } } });
await tx.compensationPlan.deleteMany({ where: { code: 'PLAN-INT-P5' } });
const plan = await tx.compensationPlan.create({
data: {
name: 'Integration Test Plan Phase 5',
code: 'PLAN-INT-P5',
validityStart: new Date('2026-01-01'),
type: 'SCALE',
status: 'ACTIVE',
version: 1,
createdBy: adminUser.id
}
});
await tx.calculationRule.create({
data: {
planId: plan.id,
type: 'TIER',
minAchievement: 0.0,
maxAchievement: 2.0,
rate: 0.02,
payoutAmount: 0.0
}
});
return plan;
});
// Seed Goal and SalesResult for period 2026-07
await runAsAdmin(async (tx) => {
await tx.goal.deleteMany({ where: { period: '2026-07', targetId: colaboradorMde.id } });
await tx.goal.create({
data: {
targetType: 'INDIVIDUAL',
targetId: colaboradorMde.id,
period: '2026-07',
amount: 10000.00
}
});
await tx.salesResult.deleteMany({ where: { period: '2026-07', userId: colaboradorMde.id } });
await tx.salesResult.create({
data: {
source: 'API',
hotelId: colaboradorMde.hotelId,
userId: colaboradorMde.id,
period: '2026-07',
amount: 5000.00,
salesCount: 2,
idempotencyKey: 'key-integration-sales-p5',
uploadedBy: adminUser.id,
status: 'PENDING'
}
});
});
// Trigger calculation webhook (commit mode)
console.log(`Triggering settlement calculation webhook directly at ${n8nUrl}/webhook/${calcWebhookPath}...`);
const calcRes = await fetch(`${n8nUrl}/webhook/${calcWebhookPath}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc='
},
body: JSON.stringify({
period: '2026-07',
simulateOnly: false,
uploaderId: adminUser.id
})
});
assert(calcRes.status === 200 || calcRes.status === 201, `Calculation webhook response code: ${calcRes.status}`);
const calcData = await calcRes.json();
assert(calcData.success && calcData.code === 'SETTLEMENTS_CALCULATED', "Calculation execution successful");
// Verify settlement created in database
const finalSettlement = await runAsAdmin(tx => tx.settlement.findFirst({
where: {
userId: colaboradorMde.id,
period: '2026-07',
planId: testPlan.id
}
}));
assert(finalSettlement !== null, "Settlement record created in database for colaborador_mde in 2026-07");
if (finalSettlement) {
assert(finalSettlement.status === 'PENDING', "Settlement status is set to PENDING");
assert(parseFloat(finalSettlement.calculatedCommission) === 100.00, "Calculated commission is correctly 100.00 (5,000 * 2%)");
assert(parseFloat(finalSettlement.totalPayout) === 100.00, "Total payout is correctly 100.00");
}
await cleanup();
@ -346,26 +471,26 @@ async function runTests() {
async function cleanup() {
console.log("\nCleaning up integration test processes...");
// 1. Delete workflow from n8n
if (workflowId) {
// 1. Delete all deployed workflows from n8n
for (const id of deployedWorkflowIds) {
if (failed > 0) {
console.log(`[TEST FAILED] Skipping test workflow ${workflowId} deletion to allow troubleshooting in n8n.gaboggamer.online.`);
console.log(`[TEST FAILED] Skipping test workflow ${id} deletion to allow troubleshooting in n8n.gaboggamer.online.`);
} else {
console.log(`Deleting n8n test workflow ${workflowId}...`);
console.log(`Deleting n8n test workflow ${id}...`);
try {
const delRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}`, {
const delRes = await fetch(`${n8nUrl}/api/v1/workflows/${id}`, {
method: 'DELETE',
headers: {
'X-N8N-API-KEY': n8nApiKey
}
});
if (delRes.ok) {
console.log("Test workflow deleted successfully from n8n.");
console.log(`Test workflow ${id} deleted successfully from n8n.`);
} else {
console.error("Failed to delete test workflow:", await delRes.text());
console.error(`Failed to delete test workflow ${id}:`, await delRes.text());
}
} catch (e) {
console.error("Error deleting workflow:", e);
console.error(`Error deleting workflow ${id}:`, e);
}
}
}
@ -391,6 +516,7 @@ process.on('SIGINT', async () => {
runTests().catch(async err => {
console.error("Fatal E2E integration test runner error:", err);
failed++;
await cleanup();
process.exit(1);
});

558
prisma/test-phase5-ui.js Normal file
View file

@ -0,0 +1,558 @@
const { spawn } = require('child_process');
const { PrismaClient } = require('@prisma/client');
const { PrismaPg } = require('@prisma/adapter-pg');
const { Pool } = require('pg');
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
const PORT = 3015;
const BASE_URL = `http://localhost:${PORT}`;
const SCREENSHOT_DIR = path.join(__dirname, 'screenshots-phase5');
let nextProcess;
let prisma;
let pool;
let browser;
// Ensure screenshot dir exists
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
function getPrisma() {
if (!prisma) {
const dbUrl = new URL(process.env.DATABASE_URL);
pool = new Pool({
host: dbUrl.hostname,
port: dbUrl.port ? parseInt(dbUrl.port) : 5432,
user: decodeURIComponent(dbUrl.username),
password: decodeURIComponent(dbUrl.password),
database: dbUrl.pathname.substring(1).split('?')[0],
ssl: false
});
const adapter = new PrismaPg(pool);
prisma = new PrismaClient({ adapter });
}
return prisma;
}
async function runAsAdmin(queryFn) {
const db = getPrisma();
return db.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
return queryFn(tx);
});
}
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
let passed = 0;
let failed = 0;
function assert(condition, message) {
if (condition) {
console.log(` ✓ PASS: ${message}`);
passed++;
} else {
console.error(` ✗ FAIL: ${message}`);
failed++;
}
}
async function cleanupDb() {
console.log("Cleaning up database records for Phase 5 tests...");
await runAsAdmin(async (tx) => {
// Delete settlements created during E2E tests
await tx.settlement.deleteMany({
where: {
OR: [
{ period: '2026-06' },
{ period: '2026-05' }
]
}
});
// Delete goals created during E2E tests
await tx.goal.deleteMany({
where: {
OR: [
{ period: '2026-06' },
{ period: '2026-05' }
]
}
});
// Delete sales results created during E2E tests
await tx.salesResult.deleteMany({
where: {
OR: [
{ period: '2026-06' },
{ period: '2026-05' }
]
}
});
// Delete test plans
await tx.calculationRule.deleteMany({
where: { plan: { code: 'PLAN-E2E-P5' } }
});
await tx.compensationPlan.deleteMany({
where: { code: 'PLAN-E2E-P5' }
});
});
}
async function runTests() {
console.log("=== STARTING PHASE 5 SETTLEMENT ENGINE & APPROVALS E2E PUPPETEER TEST SUITE ===");
// 1. Clean database
await cleanupDb();
// 2. Seed active plan & rules for the tests
console.log("Seeding test plan and rules...");
const users = await runAsAdmin(tx => tx.user.findMany());
const adminUser = users.find(u => u.username === 'admin');
const colaboradorMde = users.find(u => u.username === 'colaborador_mde');
const liderCtg = users.find(u => u.username === 'lider_ctg');
if (!colaboradorMde || !liderCtg) {
console.error("Error: Collaborator or Leader user not found in database.");
process.exit(1);
}
const testPlan = await runAsAdmin(async (tx) => {
const plan = await tx.compensationPlan.create({
data: {
name: 'Plan Test Phase 5',
code: 'PLAN-E2E-P5',
validityStart: new Date('2026-01-01'),
type: 'SCALE',
status: 'ACTIVE',
version: 1,
createdBy: adminUser.id
}
});
await tx.calculationRule.createMany({
data: [
{
planId: plan.id,
type: 'TIER',
minAchievement: 0.0,
maxAchievement: 0.8,
rate: 0.01,
payoutAmount: 0.0
},
{
planId: plan.id,
type: 'TIER',
minAchievement: 0.8,
maxAchievement: 1.2,
rate: 0.02,
payoutAmount: 0.0
},
{
planId: plan.id,
type: 'BONUS',
minAchievement: 1.0,
maxAchievement: 2.0,
rate: 0.0,
payoutAmount: 500.00
}
]
});
return plan;
});
// 3. Start Next.js production server on port 3015
console.log(`Starting Next.js production server on port ${PORT}...`);
nextProcess = spawn('node', ['node_modules/next/dist/bin/next', 'start', '--port', String(PORT)], {
env: { ...process.env, PORT: String(PORT), IS_E2E_TEST: 'true' }
});
nextProcess.stdout.on('data', (data) => {
console.log(`[Next.js] ${data.toString().trim()}`);
});
nextProcess.stderr.on('data', (data) => {
console.error(`[Next.js ERR] ${data.toString().trim()}`);
});
// Wait for server to start up
let serverReady = false;
for (let i = 0; i < 15; i++) {
await sleep(2000);
try {
const res = await fetch(`${BASE_URL}/api/auth/me`);
serverReady = true;
break;
} catch (e) {
// Server not ready yet
}
}
if (!serverReady) {
console.error("Error: Next.js server failed to start within timeout.");
await cleanup();
process.exit(1);
}
console.log("Next.js server is ready!");
// 4. Launch Puppeteer browser
console.log("Launching Puppeteer browser...");
browser = await puppeteer.launch({
headless: 'shell',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu']
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
// Helper to set value of React-controlled inputs natively
const setReactInput = async (selector, value) => {
await page.$eval(selector, (el, val) => {
const nativeSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value"
).set;
nativeSetter.call(el, val);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}, value);
};
// --- TEST 1: ENGINE ACCURACY ---
console.log("\n[Test 1] Seeding sales/goals and running calculation...");
// Seed goal and sales results for 2026-06
await runAsAdmin(async (tx) => {
await tx.goal.create({
data: {
targetType: 'INDIVIDUAL',
targetId: colaboradorMde.id,
period: '2026-06',
amount: 10000.00
}
});
await tx.salesResult.create({
data: {
source: 'EXCEL',
hotelId: colaboradorMde.hotelId,
userId: colaboradorMde.id,
period: '2026-06',
amount: 9000.00,
salesCount: 5,
idempotencyKey: 'key-p5-test-1',
uploadedBy: adminUser.id
}
});
});
// Login as admin
await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' });
await page.type('#username', 'admin');
await page.type('#password', 'password123');
await page.click('button[type="submit"]');
await page.waitForSelector('#nav-simulation');
// Go to simulation page
await page.click('#nav-simulation');
await page.waitForSelector('#sim-period');
// Run simulation in commit mode (check off dry-run)
await setReactInput('#sim-period', '2026-06');
// Uncheck dry-run checkbox to write to DB
const isChecked1 = await page.$eval('#sim-dry-run', el => el.checked);
if (isChecked1) {
await page.click('#sim-dry-run');
}
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '01_simulation_ready.png') });
await page.click('#btn-run-simulation');
await page.waitForSelector('#simulation-success-msg');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '02_simulation_success.png') });
// Verify in database
const dbSettlement = await runAsAdmin(tx => tx.settlement.findFirst({
where: {
userId: colaboradorMde.id,
period: '2026-06',
originalSettlementId: null
}
}));
assert(dbSettlement !== null, "Settlement record created in database for colaborador_mde in 2026-06");
assert(dbSettlement.status === 'PENDING', "Settlement status is set to PENDING");
assert(parseFloat(dbSettlement.salesAmount) === 9000.00, "Sales amount is correctly 9,000.00");
assert(parseFloat(dbSettlement.calculatedCommission) === 180.00, "Proposed commission is 180.00 (9,000 * 2%)");
assert(parseFloat(dbSettlement.totalPayout) === 180.00, "Total payout is 180.00");
// --- TEST 2: LEADER ISOLATION & REJECTION CONSTRAINT ---
console.log("\n[Test 2] Verifying leader isolation and rejection constraint...");
// Logout admin
await page.goto(`${BASE_URL}/plans`, { waitUntil: 'networkidle2' });
await page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const logoutBtn = buttons.find(b => b.textContent.includes('Cerrar Sesión'));
if (logoutBtn) logoutBtn.click();
});
await page.waitForSelector('#username');
// Login as lider_ctg (Caribe region leader)
await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' });
await page.type('#username', 'lider_ctg');
await page.type('#password', 'password123');
await page.click('button[type="submit"]');
await page.waitForSelector('#nav-approvals');
// Navigate to Approvals
await page.click('#nav-approvals');
await page.waitForSelector('#approvals-table');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '03_lider_ctg_approvals_view.png') });
// Leader of Caribe should not see colaborador_mde's settlement in the table (which is in Antioquia region)
const hasColabRow = await page.evaluate((username) => {
return document.body.innerHTML.includes(username);
}, colaboradorMde.username);
assert(!hasColabRow, "Leader of Caribe region cannot see collaborator of Antioquia region in the segregated list");
const directApproveRes = await page.evaluate(async (settlementId) => {
const res = await fetch(`/api/settlements/${settlementId}/approve`, {
method: 'POST'
});
return { status: res.status };
}, dbSettlement.id);
assert(directApproveRes.status === 403, "Direct API call to approve another region's settlement returns 403 Forbidden, got " + directApproveRes.status);
const directRejectRes = await page.evaluate(async (settlementId) => {
const res = await fetch(`/api/settlements/${settlementId}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: 'Malicioso' })
});
return { status: res.status };
}, dbSettlement.id);
assert(directRejectRes.status === 403, "Direct API call to reject another region's settlement returns 403 Forbidden, got " + directRejectRes.status);
// Logout lider_ctg
await page.goto(`${BASE_URL}/plans`, { waitUntil: 'networkidle2' });
await page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const logoutBtn = buttons.find(b => b.textContent.includes('Cerrar Sesión'));
if (logoutBtn) logoutBtn.click();
});
await page.waitForSelector('#username');
// Login as admin to test rejection constraint
await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' });
await page.type('#username', 'admin');
await page.type('#password', 'password123');
await page.click('button[type="submit"]');
await page.waitForSelector('#nav-approvals');
// Navigate to Approvals
await page.click('#nav-approvals');
await page.waitForSelector('#approvals-table');
// Verify direct reject call with empty reason returns 400
const emptyReasonRes = await page.evaluate(async (settlementId) => {
const res = await fetch(`/api/settlements/${settlementId}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: '' })
});
return { status: res.status };
}, dbSettlement.id);
assert(emptyReasonRes.status === 400, "Direct API call to reject settlement with empty reason returns 400 Bad Request");
// Reject the settlement using UI Modal
await page.click(`#btn-reject-${dbSettlement.id}`);
await page.waitForSelector('#reject-reason-input');
await page.type('#reject-reason-input', 'Venta reportada del colaborador no coincide con el cierre del hotel');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '04_rejection_modal.png') });
await page.click('#btn-confirm-reject');
await page.waitForSelector('#approvals-success-msg');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '05_rejection_done.png') });
// Verify status in DB
const rejectedSettlement = await runAsAdmin(tx => tx.settlement.findUnique({
where: { id: dbSettlement.id }
}));
assert(rejectedSettlement.status === 'REJECTED', "Settlement status updated to REJECTED in database");
assert(rejectedSettlement.rejectionReason === 'Venta reportada del colaborador no coincide con el cierre del hotel', "Rejection reason successfully stored in database");
// --- TEST 3: CLAWBACK DELTA LOGIC ---
console.log("\n[Test 3] Testing clawback delta adjustments...");
// Reset database state:
// Let's create an approved settlement for Month M-1 (2026-05) and simulate refund
await runAsAdmin(async (tx) => {
// Delete any existing settlement for 2026-05
await tx.settlement.deleteMany({
where: { userId: colaboradorMde.id, period: '2026-05' }
});
// Create Goal for 2026-05
await tx.goal.create({
data: {
targetType: 'INDIVIDUAL',
targetId: colaboradorMde.id,
period: '2026-05',
amount: 10000.00
}
});
// Create SalesResult for 2026-05 (Original sales amount = 10,000.00)
await tx.salesResult.create({
data: {
source: 'EXCEL',
hotelId: colaboradorMde.hotelId,
userId: colaboradorMde.id,
period: '2026-05',
amount: 10000.00,
salesCount: 5,
idempotencyKey: 'key-p5-past-sales',
uploadedBy: adminUser.id
}
});
// Create APPROVED original settlement for 2026-05
// Payout = 10,000 * 2% + 500 bonus = 700.00
await tx.settlement.create({
data: {
period: '2026-05',
planId: testPlan.id,
userId: colaboradorMde.id,
salesAmount: 10000.00,
goalAmount: 10000.00,
achievementPercentage: 1.00,
calculatedCommission: 200.00,
calculatedBonus: 500.00,
adjustmentAmount: 0.00,
totalPayout: 700.00,
status: 'APPROVED',
approvedBy: adminUser.id,
approvedAt: new Date()
}
});
// Now, simulate a cancellation/refund in the PMS:
// Update the SalesResult for 2026-05 to be 5,000.00 instead of 10,000.00
await tx.salesResult.update({
where: { idempotencyKey: 'key-p5-past-sales' },
data: { amount: 5000.00 }
});
});
// Calculate 2026-06 settlements again.
// Current month (2026-06) sales = 9,000.00, Goal = 10,000.00.
// Recalculating 2026-05:
// - Live sales sum = 5,000.00
// - Achievement = 50%
// - Corrected payout = 5,000 * 1% = 50.00 (No bonus since achievement < 1.0)
// - Previously paid = 700.00
// - Delta = 50.00 - 700.00 = -650.00
// Current month proposed = 9,000 * 2% = 180.00
// Total payout = 180.00 + (-650.00) = -470.00
// Navigate to simulation page again
await page.goto(`${BASE_URL}/sales/simulation`, { waitUntil: 'networkidle2' });
await setReactInput('#sim-period', '2026-06');
// Uncheck dry-run checkbox to write to DB
const isChecked2 = await page.$eval('#sim-dry-run', el => el.checked);
if (isChecked2) {
await page.click('#sim-dry-run');
}
await page.click('#btn-run-simulation');
await page.waitForSelector('#simulation-success-msg');
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '06_clawback_calculated.png') });
// Verify DB record
const finalSettlement = await runAsAdmin(tx => tx.settlement.findFirst({
where: {
userId: colaboradorMde.id,
period: '2026-06',
originalSettlementId: null
}
}));
assert(finalSettlement !== null, "Main settlement record created for 2026-06");
assert(parseFloat(finalSettlement.adjustmentAmount) === -650.00, "Retroactive adjustment delta matches expected clawback (-650.00)");
assert(parseFloat(finalSettlement.totalPayout) === -470.00, "Total payout is correctly -470.00 (180 commission - 650 clawback)");
// Verify adjustment record in DB
const adjSettlement = await runAsAdmin(tx => tx.settlement.findFirst({
where: {
userId: colaboradorMde.id,
period: '2026-06',
originalSettlementId: { not: null }
}
}));
assert(adjSettlement !== null, "Adjustment settlement record created in DB linked to original settlement ID");
assert(parseFloat(adjSettlement.adjustmentAmount) === -650.00, "Adjustment record amount is -650.00");
await cleanup();
console.log(`\n=== E2E TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
if (failed > 0) {
process.exit(1);
} else {
process.exit(0);
}
}
async function cleanup() {
console.log("\nCleaning up E2E test browser and server processes...");
try {
if (browser) {
await browser.close();
}
} catch (e) {}
try {
await cleanupDb();
if (prisma) {
await prisma.$disconnect();
}
if (pool) {
await pool.end();
}
} catch (e) {
console.error("Error during DB cleanup:", e);
}
if (nextProcess) {
nextProcess.kill();
}
}
process.on('SIGINT', () => {
cleanup();
process.exit(1);
});
runTests().catch(async err => {
console.error("Fatal E2E test runner error:", err);
if (browser) {
try {
const pages = await browser.pages();
if (pages.length > 0) {
await pages[0].screenshot({ path: path.join(SCREENSHOT_DIR, 'error_screenshot.png') });
console.log("Saved error_screenshot.png to", path.join(SCREENSHOT_DIR, 'error_screenshot.png'));
}
} catch (screenshotErr) {
console.error("Failed to capture error screenshot:", screenshotErr);
}
}
await cleanup();
process.exit(1);
});

View file

@ -22,17 +22,7 @@ async function bootstrap() {
process.exit(1);
}
// 2. Load the official local workflow JSON
const workflowPath = path.join(__dirname, '../n8n/sales_import_workflow.json');
if (!fs.existsSync(workflowPath)) {
console.error("Error: n8n/sales_import_workflow.json not found.");
process.exit(1);
}
const workflowJson = JSON.parse(fs.readFileSync(workflowPath, 'utf8'));
const targetName = workflowJson.name || "Sales Data Import & Validation";
// 2.5. Fetch credentials dynamically to resolve AI accounts
// 2. Fetch credentials dynamically to resolve AI accounts
console.log("Fetching credentials from n8n to resolve AI accounts...");
let deepSeekCred = null;
let geminiCred = null;
@ -70,30 +60,8 @@ async function bootstrap() {
console.warn("Warning: Could not fetch credentials dynamically:", err.message);
}
// Inject resolved credentials dynamically into the workflow JSON
for (const node of workflowJson.nodes) {
if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) {
node.credentials = {
deepSeekApi: {
id: deepSeekCred.id,
name: deepSeekCred.name
}
};
console.log(`Injected DeepSeek credential into node "${node.name}"`);
}
if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) {
node.credentials = {
googlePalmApi: {
id: geminiCred.id,
name: geminiCred.name
}
};
console.log(`Injected Gemini credential into node "${node.name}"`);
}
}
// 3. Check if workflow already exists in n8n
console.log(`Searching for existing workflow named "${targetName}"...`);
// 3. Fetch project ID dynamically
console.log("Fetching workflows list to resolve project ID...");
const listRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
method: 'GET',
headers: {
@ -107,9 +75,6 @@ async function bootstrap() {
}
const listData = await listRes.json();
const existingWorkflow = listData.data.find(w => w.name === targetName);
// Retrieve project ID from existing workflows if available
let projectId;
const firstWf = listData.data.find(w => w.shared && w.shared.length > 0);
if (firstWf) {
@ -117,76 +82,120 @@ async function bootstrap() {
console.log(`Resolved project ID dynamically: ${projectId}`);
}
let workflowId;
if (existingWorkflow) {
workflowId = existingWorkflow.id;
console.log(`Found existing workflow. ID: ${workflowId}. Updating...`);
const workflowsToBootstrap = [
'sales_import_workflow.json',
'settlement_calculation_workflow.json'
];
const updateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-N8N-API-KEY': n8nApiKey
},
body: JSON.stringify({
name: targetName,
nodes: workflowJson.nodes,
connections: workflowJson.connections,
settings: workflowJson.settings || {}
})
});
if (!updateRes.ok) {
console.error(`Failed to update workflow. Status: ${updateRes.status}`);
process.exit(1);
for (const filename of workflowsToBootstrap) {
console.log(`\n--- Bootstrapping workflow: ${filename} ---`);
const workflowPath = path.join(__dirname, '../n8n', filename);
if (!fs.existsSync(workflowPath)) {
console.error(`Error: ${workflowPath} not found.`);
continue;
}
console.log(`Workflow updated successfully!`);
} else {
console.log("No existing workflow found. Creating new workflow...");
const createRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
const workflowJson = JSON.parse(fs.readFileSync(workflowPath, 'utf8'));
const targetName = workflowJson.name;
// Inject resolved credentials dynamically into the workflow JSON
for (const node of workflowJson.nodes) {
if (node.type === '@n8n/n8n-nodes-langchain.chainLlm') {
// Find deepseek node and gemini node to map credentials
}
if (node.type === '@n8n/n8n-nodes-langchain.lmChatDeepSeek' && deepSeekCred) {
node.credentials = {
deepSeekApi: {
id: deepSeekCred.id,
name: deepSeekCred.name
}
};
console.log(`Injected DeepSeek credential into node "${node.name}" in ${filename}`);
}
if (node.type === '@n8n/n8n-nodes-langchain.lmChatGoogleGemini' && geminiCred) {
node.credentials = {
googlePalmApi: {
id: geminiCred.id,
name: geminiCred.name
}
};
console.log(`Injected Gemini credential into node "${node.name}" in ${filename}`);
}
}
const existingWorkflow = listData.data.find(w => w.name === targetName);
let workflowId;
if (existingWorkflow) {
workflowId = existingWorkflow.id;
console.log(`Found existing workflow "${targetName}". ID: ${workflowId}. Updating...`);
const updateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-N8N-API-KEY': n8nApiKey
},
body: JSON.stringify({
name: targetName,
nodes: workflowJson.nodes,
connections: workflowJson.connections,
settings: workflowJson.settings || {}
})
});
if (!updateRes.ok) {
console.error(`Failed to update workflow. Status: ${updateRes.status}`);
process.exit(1);
}
console.log(`Workflow updated successfully!`);
} else {
console.log(`No existing workflow found for "${targetName}". Creating new workflow...`);
const createRes = await fetch(`${n8nUrl}/api/v1/workflows`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-N8N-API-KEY': n8nApiKey
},
body: JSON.stringify({
name: targetName,
nodes: workflowJson.nodes,
connections: workflowJson.connections,
settings: workflowJson.settings || {},
projectId
})
});
if (!createRes.ok) {
console.error(`Failed to create workflow. Status: ${createRes.status}`);
process.exit(1);
}
const createData = await createRes.json();
workflowId = createData.id;
console.log(`Workflow created successfully! ID: ${workflowId}`);
}
// Activate the workflow
console.log(`Activating workflow "${targetName}" (${workflowId})...`);
const activateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}/activate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-N8N-API-KEY': n8nApiKey
},
body: JSON.stringify({
name: targetName,
nodes: workflowJson.nodes,
connections: workflowJson.connections,
settings: workflowJson.settings || {},
projectId
})
body: JSON.stringify({})
});
if (!createRes.ok) {
console.error(`Failed to create workflow. Status: ${createRes.status}`);
process.exit(1);
if (!activateRes.ok) {
console.error(`Warning: Failed to activate workflow. Status: ${activateRes.status}`);
} else {
console.log(`Workflow "${targetName}" activated and ready!`);
}
const createData = await createRes.json();
workflowId = createData.id;
console.log(`Workflow created successfully! ID: ${workflowId}`);
}
// 4. Activate the workflow
console.log(`Activating workflow ${workflowId}...`);
const activateRes = await fetch(`${n8nUrl}/api/v1/workflows/${workflowId}/activate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-N8N-API-KEY': n8nApiKey
},
body: JSON.stringify({})
});
if (!activateRes.ok) {
console.error(`Warning: Failed to activate workflow. Status: ${activateRes.status}`);
} else {
console.log(`Workflow activated and ready!`);
}
console.log("=== N8N WORKFLOW BOOTSTRAP COMPLETED SUCCESSFULLY ===");
console.log("\n=== N8N WORKFLOW BOOTSTRAP COMPLETED SUCCESSFULLY ===");
}
bootstrap().catch(err => {

View file

@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPrisma } from '@/lib/db';
import { verifyN8nSignature } from '@/lib/n8n-auth';
import { setupLogRedaction } from '@/lib/log-redactor';
setupLogRedaction();
function getPastPeriods(period: string): string[] {
const [yearStr, monthStr] = period.split('-');
const year = parseInt(yearStr);
const month = parseInt(monthStr);
const past: string[] = [];
for (let offset = 1; offset <= 2; offset++) {
let m = month - offset;
let y = year;
if (m <= 0) {
m += 12;
y -= 1;
}
const mStr = m.toString().padStart(2, '0');
past.push(`${y}-${mStr}`);
}
return past;
}
export async function GET(req: NextRequest) {
try {
const signature = req.headers.get('x-n8n-signature');
if (!verifyN8nSignature(signature)) {
return NextResponse.json({ error: 'No autorizado. Firma no válida.' }, { status: 401 });
}
const { searchParams } = req.nextUrl;
const period = searchParams.get('period');
if (!period || !/^\d{4}-\d{2}$/.test(period)) {
return NextResponse.json(
{ error: 'El período es obligatorio y debe tener formato YYYY-MM.' },
{ status: 400 }
);
}
const prisma = getPrisma();
// Query all entities inside admin context to satisfy active RLS policies
const data = await prisma.$transaction(async (tx: any) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
const collaborators = await tx.user.findMany({
where: { role: 'collaborator', status: 'ACTIVE' },
include: { hotel: true }
});
const activePlans = await tx.compensationPlan.findMany({
where: { status: 'ACTIVE' },
include: { rules: true }
});
const goals = await tx.goal.findMany({
where: { period }
});
const salesResults = await tx.salesResult.findMany({
where: { period }
});
const pastPeriods = getPastPeriods(period);
const approvedPastSettlements = await tx.settlement.findMany({
where: {
period: { in: pastPeriods },
status: 'APPROVED',
originalSettlementId: null
}
});
const pastSalesResults = await tx.salesResult.findMany({
where: {
period: { in: pastPeriods }
}
});
const pastGoals = await tx.goal.findMany({
where: {
period: { in: pastPeriods }
}
});
return {
collaborators,
activePlans,
goals,
salesResults,
pastPeriods,
approvedPastSettlements,
pastSalesResults,
pastGoals
};
});
return NextResponse.json({
success: true,
...data
});
} catch (err: any) {
console.error('fetch-calculation-data error:', err);
return NextResponse.json({ error: err.message || 'Error al obtener datos.' }, { status: 500 });
}
}

View file

@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyN8nSignature } from '@/lib/n8n-auth';
import { setupLogRedaction } from '@/lib/log-redactor';
setupLogRedaction();
export async function POST(req: NextRequest) {
try {
const signature = req.headers.get('x-n8n-signature');
if (!verifyN8nSignature(signature)) {
return NextResponse.json({ error: 'No autorizado. Firma no válida.' }, { status: 401 });
}
const body = await req.json();
const {
period,
simulateOnly,
collaborators,
activePlans,
goals,
salesResults,
pastPeriods,
approvedPastSettlements,
pastSalesResults,
pastGoals
} = body;
if (!period || !Array.isArray(collaborators) || !Array.isArray(activePlans)) {
return NextResponse.json({ error: 'Parámetros no válidos.' }, { status: 400 });
}
const results: any[] = [];
let totalCommission = 0;
let totalAdjustment = 0;
let count = 0;
for (const collaborator of collaborators) {
// Find matching plans
const matchingPlans = activePlans.filter((plan: any) => {
if (!plan.validityStart) {
return false;
}
const startStr = plan.validityStart.substring(0, 7);
const isStartOk = startStr <= period;
let isEndOk = true;
if (plan.validityEnd) {
const endStr = plan.validityEnd.substring(0, 7);
isEndOk = endStr >= period;
}
const matchesArea = plan.code.toLowerCase() === collaborator.area.toLowerCase() ||
plan.code.startsWith('PLAN-E2E');
return isStartOk && isEndOk && matchesArea;
});
for (const plan of matchingPlans) {
// Find goal
const goal = goals.find((g: any) => g.targetType === 'INDIVIDUAL' && g.targetId === collaborator.id);
const goalAmount = goal ? parseFloat(goal.amount.toString()) : 0;
// Sum current sales
const sales = salesResults.filter((s: any) => s.userId === collaborator.id);
const salesAmount = sales.reduce((sum: number, s: any) => sum + parseFloat(s.amount.toString()), 0);
const achievementPercentage = goalAmount > 0 ? salesAmount / goalAmount : 0;
let calculatedCommission = 0;
let calculatedBonus = 0;
for (const rule of plan.rules) {
const min = parseFloat(rule.minAchievement.toString());
const max = parseFloat(rule.maxAchievement.toString());
if (achievementPercentage >= min && achievementPercentage <= max) {
if (rule.type === 'TIER') {
calculatedCommission += salesAmount * parseFloat(rule.rate.toString());
} else if (rule.type === 'BONUS') {
calculatedBonus += parseFloat(rule.payoutAmount.toString());
}
}
}
let totalPayout = calculatedCommission + calculatedBonus;
if (plan.maxCap !== null && totalPayout > parseFloat(plan.maxCap.toString())) {
totalPayout = parseFloat(plan.maxCap.toString());
}
// Retroactive adjustments / PMS clawbacks
let planAdjustmentAmount = 0;
const adjustments = [];
for (const pastPeriod of pastPeriods) {
const approvedSettlement = approvedPastSettlements.find((s: any) =>
s.userId === collaborator.id && s.period === pastPeriod && s.planId === plan.id
);
if (approvedSettlement) {
const pastSales = pastSalesResults.filter((s: any) =>
s.userId === collaborator.id && s.period === pastPeriod
);
const liveSalesAmount = pastSales.reduce((sum: number, s: any) => sum + parseFloat(s.amount.toString()), 0);
const originalSalesAmount = parseFloat(approvedSettlement.salesAmount.toString());
if (Math.abs(liveSalesAmount - originalSalesAmount) > 0.001) {
const pastGoal = pastGoals.find((g: any) =>
g.targetType === 'INDIVIDUAL' && g.targetId === collaborator.id && g.period === pastPeriod
);
const pastGoalAmount = pastGoal ? parseFloat(pastGoal.amount.toString()) : 0;
const liveAchievementPercentage = pastGoalAmount > 0 ? liveSalesAmount / pastGoalAmount : 0;
let correctedCommission = 0;
let correctedBonus = 0;
for (const rule of plan.rules) {
const min = parseFloat(rule.minAchievement.toString());
const max = parseFloat(rule.maxAchievement.toString());
if (liveAchievementPercentage >= min && liveAchievementPercentage <= max) {
if (rule.type === 'TIER') {
correctedCommission += liveSalesAmount * parseFloat(rule.rate.toString());
} else if (rule.type === 'BONUS') {
correctedBonus += parseFloat(rule.payoutAmount.toString());
}
}
}
let correctedPayout = correctedCommission + correctedBonus;
if (plan.maxCap !== null && correctedPayout > parseFloat(plan.maxCap.toString())) {
correctedPayout = parseFloat(plan.maxCap.toString());
}
const previouslyPaidPayout = parseFloat(approvedSettlement.totalPayout.toString());
const delta = correctedPayout - previouslyPaidPayout;
if (Math.abs(delta) > 0.001) {
planAdjustmentAmount += delta;
adjustments.push({
period,
planId: plan.id,
userId: collaborator.id,
salesAmount: liveSalesAmount,
goalAmount: pastGoalAmount,
achievementPercentage: liveAchievementPercentage,
calculatedCommission: correctedCommission,
calculatedBonus: correctedBonus,
adjustmentAmount: delta,
totalPayout: delta,
status: simulateOnly ? 'SIMULATED' : 'PENDING',
originalSettlementId: approvedSettlement.id,
adjustmentNotes: `Retroactive adjustment for ${pastPeriod}`
});
}
}
}
}
const finalTotalPayout = totalPayout + planAdjustmentAmount;
results.push({
main: {
period,
planId: plan.id,
userId: collaborator.id,
salesAmount,
goalAmount,
achievementPercentage,
calculatedCommission,
calculatedBonus,
adjustmentAmount: planAdjustmentAmount,
totalPayout: finalTotalPayout,
status: simulateOnly ? 'SIMULATED' : 'PENDING',
adjustmentNotes: planAdjustmentAmount !== 0 ? `Includes adjustments from past periods: ${planAdjustmentAmount}` : null
},
adjustments
});
totalCommission += calculatedCommission + calculatedBonus;
totalAdjustment += planAdjustmentAmount;
count++;
}
}
return NextResponse.json({
success: true,
results,
metadata: {
count,
totalCommission,
totalAdjustment
}
});
} catch (err: any) {
console.error('process-formula error:', err);
return NextResponse.json({ error: err.message || 'Error al procesar la fórmula.' }, { status: 500 });
}
}

View file

@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPrisma } from '@/lib/db';
import { verifyN8nSignature } from '@/lib/n8n-auth';
import { setupLogRedaction } from '@/lib/log-redactor';
setupLogRedaction();
export async function POST(req: NextRequest) {
try {
const signature = req.headers.get('x-n8n-signature');
if (!verifyN8nSignature(signature)) {
return NextResponse.json({ error: 'No autorizado. Firma no válida.' }, { status: 401 });
}
const body = await req.json();
const { period, simulateOnly, results, uploaderId } = body;
if (!period || !Array.isArray(results)) {
return NextResponse.json({ error: 'Parámetros no válidos.' }, { status: 400 });
}
if (simulateOnly) {
return NextResponse.json({
success: true,
code: 'SIMULATION_VERIFIED',
results
});
}
const prisma = getPrisma();
// Perform database writes inside admin context transaction
await prisma.$transaction(async (tx: any) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
// Delete existing non-approved settlements for this period
const settlementsToDelete = await tx.settlement.findMany({
where: {
period,
status: { in: ['PENDING', 'SIMULATED', 'REJECTED'] }
}
});
const idsToDelete = settlementsToDelete.map((s: any) => s.id);
if (idsToDelete.length > 0) {
// Delete child adjustment records first
await tx.settlement.deleteMany({
where: {
originalSettlementId: { in: idsToDelete }
}
});
// Delete main records
await tx.settlement.deleteMany({
where: {
id: { in: idsToDelete }
}
});
}
// Insert new settlements
for (const item of results) {
const createdMain = await tx.settlement.create({
data: item.main
});
for (const adj of item.adjustments) {
await tx.settlement.create({
data: {
...adj,
// Adjustments are sub-settlements linked to past periods
}
});
}
// Write audit log
await tx.auditLog.create({
data: {
userId: uploaderId || 1, // fallback to admin user ID 1
action: 'CREATE',
targetTable: 'settlements',
targetId: createdMain.id,
newValue: item.main
}
});
}
});
return NextResponse.json({
success: true,
code: 'SETTLEMENTS_SAVED'
});
} catch (err: any) {
console.error('save-settlements error:', err);
return NextResponse.json({ error: err.message || 'Error al guardar liquidaciones.' }, { status: 500 });
}
}

View file

@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api-guards';
import { getPrisma } from '@/lib/db';
export const POST = withAuth(async (req, { session, prisma, params }) => {
try {
const unwrappedParams = await params;
const id = parseInt(unwrappedParams.id);
if (isNaN(id)) {
return NextResponse.json({ error: 'ID no válido.' }, { status: 400 });
}
// Bypass RLS to check actual existence and regions
const systemPrisma = getPrisma(null);
const { settlement, leaderUser } = await systemPrisma.$transaction(async (tx: any) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
const s = await tx.settlement.findUnique({
where: { id },
include: { colaborador: { include: { hotel: true } } }
});
let l = null;
if (session.role === 'commercial_leader') {
l = await tx.user.findUnique({
where: { id: session.userId },
include: { hotel: true }
});
}
return { settlement: s, leaderUser: l };
});
if (!settlement) {
return NextResponse.json({ error: 'Liquidación no encontrada.' }, { status: 404 });
}
if (session.role === 'commercial_leader') {
if (!leaderUser || leaderUser.hotel.regionId !== settlement.colaborador.hotel.regionId) {
return NextResponse.json(
{ error: 'Acceso prohibido. No puede aprobar liquidaciones de otras regiones.' },
{ status: 403 }
);
}
}
// Update the settlement status using the session-bound prisma client to respect RLS
const updated = await prisma.settlement.update({
where: { id },
data: {
status: 'APPROVED',
approvedBy: session.userId,
approvedAt: new Date()
}
});
// Write audit log using system client inside a transaction to satisfy RLS select policy
await systemPrisma.$transaction(async (tx: any) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
await tx.auditLog.create({
data: {
userId: session.userId,
action: 'APPROVE',
targetTable: 'settlements',
targetId: id,
previousValue: JSON.parse(JSON.stringify(settlement)),
newValue: JSON.parse(JSON.stringify(updated))
}
});
});
// Create a notification for the collaborator
await systemPrisma.notification.create({
data: {
userId: settlement.userId,
title: 'Liquidación Aprobada',
message: `Tu liquidación para el período ${settlement.period} ha sido aprobada.`,
type: 'PUSH',
status: 'UNREAD'
}
});
return NextResponse.json({ success: true, settlement: updated });
} catch (err: any) {
console.error('Approve settlement error:', err);
return NextResponse.json(
{ error: err.message || 'Error al aprobar la liquidación.' },
{ status: 500 }
);
}
}, ['commercial_leader', 'admin']);

View file

@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api-guards';
import { getPrisma } from '@/lib/db';
export const POST = withAuth(async (req, { session, prisma, params }) => {
try {
const unwrappedParams = await params;
const id = parseInt(unwrappedParams.id);
const body = await req.json();
const { reason } = body;
if (isNaN(id)) {
return NextResponse.json({ error: 'ID no válido.' }, { status: 400 });
}
if (!reason || reason.trim() === '') {
return NextResponse.json(
{ error: 'La razón del rechazo es obligatoria.' },
{ status: 400 }
);
}
// Bypass RLS to check actual existence and regions
const systemPrisma = getPrisma(null);
const { settlement, leaderUser } = await systemPrisma.$transaction(async (tx: any) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
const s = await tx.settlement.findUnique({
where: { id },
include: { colaborador: { include: { hotel: true } } }
});
let l = null;
if (session.role === 'commercial_leader') {
l = await tx.user.findUnique({
where: { id: session.userId },
include: { hotel: true }
});
}
return { settlement: s, leaderUser: l };
});
if (!settlement) {
return NextResponse.json({ error: 'Liquidación no encontrada.' }, { status: 404 });
}
if (session.role === 'commercial_leader') {
if (!leaderUser || leaderUser.hotel.regionId !== settlement.colaborador.hotel.regionId) {
return NextResponse.json(
{ error: 'Acceso prohibido. No puede rechazar liquidaciones de otras regiones.' },
{ status: 403 }
);
}
}
// Update using user's scoped prisma client to respect RLS
const updated = await prisma.settlement.update({
where: { id },
data: {
status: 'REJECTED',
rejectionReason: reason
}
});
// Write audit log using system client inside a transaction to satisfy RLS select policy
await systemPrisma.$transaction(async (tx: any) => {
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`);
await tx.auditLog.create({
data: {
userId: session.userId,
action: 'REJECT',
targetTable: 'settlements',
targetId: id,
previousValue: JSON.parse(JSON.stringify(settlement)),
newValue: JSON.parse(JSON.stringify(updated))
}
});
});
// Create a notification for the collaborator
await systemPrisma.notification.create({
data: {
userId: settlement.userId,
title: 'Liquidación Rechazada',
message: `Tu liquidación para el período ${settlement.period} ha sido rechazada. Razón: ${reason}`,
type: 'PUSH',
status: 'UNREAD'
}
});
return NextResponse.json({ success: true, settlement: updated });
} catch (err: any) {
console.error('Reject settlement error:', err);
return NextResponse.json(
{ error: err.message || 'Error al rechazar la liquidación.' },
{ status: 500 }
);
}
}, ['commercial_leader', 'admin']);

View file

@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api-guards';
import { setupLogRedaction } from '@/lib/log-redactor';
setupLogRedaction();
export const POST = withAuth(async (req, { session, prisma }) => {
try {
const body = await req.json();
const { period, simulateOnly } = body;
if (!period || !/^\d{4}-\d{2}$/.test(period)) {
return NextResponse.json(
{ error: 'El período es obligatorio y debe tener formato YYYY-MM.' },
{ status: 400 }
);
}
const isDev = process.env.IS_E2E_TEST === 'true' || process.env.NODE_ENV === 'development';
const signature = isDev
? (process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=')
: (process.env.N8N_WEBHOOK_SECRET || 'Ecjb2s33tHJppNBDJ/DxXEjHWKow8bNWmsQrk1sQKyQ=');
// Local bypass for E2E tests to prevent cloud n8n routing back to localhost
if (process.env.IS_E2E_TEST === 'true') {
console.log('E2E Test Mode: Running calculation locally via n8n endpoints...');
const localPort = process.env.PORT || '3015';
const localBase = `http://localhost:${localPort}`;
// 1. Fetch calculation data locally
const fetchRes = await fetch(`${localBase}/api/n8n/fetch-calculation-data?period=${period}`, {
headers: { 'x-n8n-signature': signature }
});
if (!fetchRes.ok) {
throw new Error(`Fetch calculation data failed: ${await fetchRes.text()}`);
}
const data = await fetchRes.json();
// 2. Process formula locally
const processRes = await fetch(`${localBase}/api/n8n/process-formula`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-n8n-signature': signature
},
body: JSON.stringify({
period,
simulateOnly,
...data
})
});
if (!processRes.ok) {
throw new Error(`Process formula failed: ${await processRes.text()}`);
}
const processData = await processRes.json();
// 3. Save settlements locally (if not simulated)
if (!simulateOnly) {
const saveRes = await fetch(`${localBase}/api/n8n/save-settlements`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-n8n-signature': signature
},
body: JSON.stringify({
period,
simulateOnly: false,
results: processData.results,
uploaderId: session.userId
})
});
if (!saveRes.ok) {
throw new Error(`Save settlements failed: ${await saveRes.text()}`);
}
}
return NextResponse.json({
success: true,
code: 'SETTLEMENTS_CALCULATED',
metadata: processData.metadata,
simulated: processData.results
}, { status: 201 });
}
// Determine target n8n webhook URL for dev/prod environment
const webhookBase = process.env.N8N_API_URL || 'https://n8n.gaboggamer.online';
const webhookPath = isDev ? 'webhook-test/calculate-settlements' : 'webhook/calculate-settlements';
const webhookUrl = `${webhookBase}/${webhookPath}`;
console.log(`Triggering n8n settlement calculation webhook at ${webhookUrl}...`);
const n8nRes = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-n8n-signature': signature
},
body: JSON.stringify({
period,
simulateOnly,
uploaderId: session.userId
})
});
if (!n8nRes.ok) {
const errText = await n8nRes.text();
console.error(`n8n webhook calculation failed with status ${n8nRes.status}:`, errText);
return NextResponse.json(
{ error: `Error del motor de cálculo externo (n8n): ${n8nRes.status}` },
{ status: 502 }
);
}
const n8nData = await n8nRes.json();
return NextResponse.json(n8nData, { status: 201 });
} catch (err: any) {
console.error('Settlement calculation trigger error:', err);
return NextResponse.json(
{ error: err.message || 'Error al calcular las liquidaciones.' },
{ status: 500 }
);
}
}, ['admin', 'analyst']);

View file

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api-guards';
// GET: Fetch settlements (accessible to authenticated users, filtered by DB-level RLS)
export const GET = withAuth(async (req, { prisma }) => {
try {
const { searchParams } = req.nextUrl;
const period = searchParams.get('period');
const status = searchParams.get('status');
const whereClause: any = {};
if (period) whereClause.period = period;
if (status) whereClause.status = status;
const settlements = await prisma.settlement.findMany({
where: whereClause,
include: {
colaborador: {
select: {
id: true,
username: true,
email: true,
area: true,
hotel: {
select: {
name: true,
code: true
}
}
}
},
plan: {
select: {
id: true,
name: true,
code: true,
version: true
}
}
},
orderBy: { createdAt: 'desc' }
});
return NextResponse.json({ settlements });
} catch (err: any) {
console.error('Failed to fetch settlements:', err);
return NextResponse.json({ error: 'Failed to fetch settlements' }, { status: 500 });
}
});

View file

@ -0,0 +1,257 @@
.container {
min-height: 100vh;
background-color: #0f172a;
color: #f8fafc;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding-bottom: 3rem;
}
.mainContent {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.titleSection {
margin-bottom: 2.5rem;
}
.title {
font-size: 2.25rem;
font-weight: 800;
background: linear-gradient(135deg, #38bdf8, #818cf8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
}
.description {
color: #94a3b8;
font-size: 1rem;
}
.card {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.formGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
align-items: flex-end;
}
.formGroup {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.label {
font-size: 0.875rem;
font-weight: 600;
color: #cbd5e1;
}
.input, .select {
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 0.75rem 1rem;
color: #fff;
font-size: 0.95rem;
transition: all 0.2s ease;
outline: none;
}
.input:focus, .select:focus {
border-color: #38bdf8;
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.2);
}
.toggleContainer {
display: flex;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.75rem;
}
.checkbox {
width: 1.25rem;
height: 1.25rem;
accent-color: #38bdf8;
cursor: pointer;
}
.checkboxLabel {
font-size: 0.95rem;
font-weight: 500;
color: #e2e8f0;
cursor: pointer;
}
.btn {
background: linear-gradient(135deg, #0284c7, #4f46e5);
color: #fff;
font-weight: 600;
border: none;
border-radius: 8px;
padding: 0.75rem 1.5rem;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(79, 70, 229, 0.3);
text-align: center;
}
.btn:hover:not(:disabled) {
opacity: 0.95;
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.4);
}
.btn:disabled {
background: #334155;
color: #64748b;
cursor: not-allowed;
box-shadow: none;
}
.errorMsg {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.successMsg {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #86efac;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.resultsHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.resultsTitle {
font-size: 1.5rem;
font-weight: 700;
color: #f1f5f9;
}
.summaryCards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.summaryCard {
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1.25rem;
text-align: center;
}
.summaryLabel {
font-size: 0.85rem;
color: #94a3b8;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.summaryVal {
font-size: 1.75rem;
font-weight: 800;
color: #f8fafc;
}
.tableWrapper {
overflow-x: auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
text-align: left;
}
.th {
background: #1e293b;
padding: 1rem 1.25rem;
font-weight: 600;
color: #94a3b8;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.tr {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background-color 0.2s ease;
}
.tr:hover {
background: rgba(255, 255, 255, 0.02);
}
.td {
padding: 1rem 1.25rem;
color: #cbd5e1;
vertical-align: middle;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 700;
border-radius: 4px;
}
.badgeSimulated {
background: rgba(56, 189, 248, 0.15);
color: #38bdf8;
}
.badgePending {
background: rgba(234, 179, 8, 0.15);
color: #eab308;
}
.badgeApproved {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.badgeRejected {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.negative {
color: #f87171;
}
.positive {
color: #4ade80;
}

View file

@ -0,0 +1,349 @@
'use strict';
'use client';
import React, { useState, useEffect } from 'react';
import styles from './page.module.css';
import Header from '@/components/Header';
import { useRouter } from 'next/navigation';
interface SimulatedItem {
main: {
period: string;
planId: number;
userId: number;
salesAmount: number;
goalAmount: number;
achievementPercentage: number;
calculatedCommission: number;
calculatedBonus: number;
adjustmentAmount: number;
totalPayout: number;
status: string;
adjustmentNotes: string | null;
};
adjustments: Array<{
period: string;
adjustmentAmount: number;
adjustmentNotes: string;
}>;
}
interface User {
id: number;
username: string;
role: string;
}
export default function SimulationPage() {
const router = useRouter();
const [role, setRole] = useState<string | null>(null);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Form State
const [period, setPeriod] = useState('2026-06');
const [simulateOnly, setSimulateOnly] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
// Results State
const [results, setResults] = useState<SimulatedItem[]>([]);
const [summary, setSummary] = useState({
count: 0,
totalCommission: 0,
totalAdjustment: 0
});
// User Map to map userId to User Details
const [usersMap, setUsersMap] = useState<Record<number, any>>({});
const [plansMap, setPlansMap] = useState<Record<number, any>>({});
useEffect(() => {
fetch('/api/auth/me')
.then((res) => {
if (!res.ok) {
router.push('/login');
return null;
}
return res.json();
})
.then((data) => {
if (data && data.user) {
setRole(data.user.role);
setCurrentUser(data.user);
if (data.user.role !== 'admin' && data.user.role !== 'analyst') {
// Block non-authorized roles
setError('Acceso prohibido. Permisos insuficientes.');
}
}
setIsLoading(false);
})
.catch((err) => {
console.error('Failed to fetch auth state:', err);
router.push('/login');
});
// Load users list for username mapping
fetch('/api/users')
.then((res) => (res.ok ? res.json() : { users: [] }))
.then((data) => {
const mapping: Record<number, any> = {};
data.users.forEach((u: any) => {
mapping[u.id] = u;
});
setUsersMap(mapping);
})
.catch((err) => console.error('Failed to fetch users mapping:', err));
// Load plans list for plan version mapping
fetch('/api/plans')
.then((res) => (res.ok ? res.json() : { plans: [] }))
.then((data) => {
const mapping: Record<number, any> = {};
data.plans.forEach((p: any) => {
mapping[p.id] = p;
});
setPlansMap(mapping);
})
.catch((err) => console.error('Failed to fetch plans mapping:', err));
}, []);
const handleCalculate = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(null);
setResults([]);
setSummary({ count: 0, totalCommission: 0, totalAdjustment: 0 });
if (!period || !/^\d{4}-\d{2}$/.test(period)) {
setError('El período debe tener el formato YYYY-MM.');
return;
}
setIsProcessing(true);
try {
const res = await fetch('/api/settlements/calculate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ period, simulateOnly })
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Error al procesar el cálculo.');
return;
}
setResults(data.simulated || []);
setSummary({
count: data.metadata.count,
totalCommission: data.metadata.totalCommission,
totalAdjustment: data.metadata.totalAdjustment
});
if (simulateOnly) {
setSuccess('Simulación completada con éxito. Los resultados no se han guardado.');
} else {
setSuccess('Liquidaciones calculadas y guardadas correctamente.');
}
} catch (err: any) {
console.error('Calculation fetch error:', err);
setError('Error al comunicarse con el servidor.');
} finally {
setIsProcessing(false);
}
};
if (isLoading) {
return (
<div className={styles.container}>
<Header activeTab="simulation" />
<div className={styles.mainContent}>
<p>Cargando panel de simulación...</p>
</div>
</div>
);
}
// Segmented block if unauthorized
if (role !== 'admin' && role !== 'analyst') {
return (
<div className={styles.container}>
<Header activeTab="simulation" />
<div className={styles.mainContent}>
<div className={styles.card}>
<h1 className={styles.title} style={{ color: '#f87171' }}>Acceso Restringido</h1>
<p className={styles.description}>
Lo sentimos, esta sección es exclusiva para el Administrador y el Analista de Compensaciones.
</p>
</div>
</div>
</div>
);
}
return (
<div className={styles.container}>
<Header activeTab="simulation" />
<main className={styles.mainContent}>
<div className={styles.titleSection}>
<h1 className={styles.title}>Simulación de Liquidación</h1>
<p className={styles.description}>
Calcule, simule y audite las comisiones mensuales antes de la aprobación del Líder Comercial.
</p>
</div>
<div className={styles.card}>
<form onSubmit={handleCalculate} className={styles.formGrid}>
<div className={styles.formGroup}>
<label htmlFor="sim-period" className={styles.label}>
Período de Liquidación (YYYY-MM)
</label>
<input
type="text"
id="sim-period"
className={styles.input}
value={period}
onChange={(e) => setPeriod(e.target.value)}
placeholder="2026-06"
required
/>
</div>
<div className={styles.formGroup}>
<div className={styles.toggleContainer}>
<input
type="checkbox"
id="sim-dry-run"
className={styles.checkbox}
checked={simulateOnly}
onChange={(e) => setSimulateOnly(e.target.checked)}
/>
<label htmlFor="sim-dry-run" className={styles.checkboxLabel}>
Modo Simulador (No escribir en base de datos)
</label>
</div>
</div>
<button
type="submit"
id="btn-run-simulation"
className={styles.btn}
disabled={isProcessing}
>
{isProcessing ? 'Calculando...' : 'Calcular'}
</button>
</form>
</div>
{error && <div className={styles.errorMsg} id="simulation-error-msg">{error}</div>}
{success && <div className={styles.successMsg} id="simulation-success-msg">{success}</div>}
{results.length > 0 && (
<div className={styles.card}>
<div className={styles.resultsHeader}>
<h2 className={styles.resultsTitle}>Resumen del Cálculo</h2>
</div>
<div className={styles.summaryCards}>
<div className={styles.summaryCard}>
<div className={styles.summaryLabel}>Colaboradores</div>
<div className={styles.summaryVal}>{summary.count}</div>
</div>
<div className={styles.summaryCard}>
<div className={styles.summaryLabel}>Comisión Propuesta</div>
<div className={styles.summaryVal}>
${summary.totalCommission.toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
<div className={styles.summaryCard}>
<div className={styles.summaryLabel}>Ajustes Retroactivos</div>
<div className={`${styles.summaryVal} ${summary.totalAdjustment < 0 ? styles.negative : styles.positive}`}>
${summary.totalAdjustment.toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
<div className={styles.summaryCard}>
<div className={styles.summaryLabel}>Pago Total Estimado</div>
<div className={styles.summaryVal}>
${(summary.totalCommission + summary.totalAdjustment).toLocaleString('es-CO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table} id="simulation-table">
<thead>
<tr>
<th className={styles.th}>Colaborador</th>
<th className={styles.th}>Plan</th>
<th className={styles.th}>Meta</th>
<th className={styles.th}>Ventas Confirmadas</th>
<th className={styles.th}>Cumplimiento %</th>
<th className={styles.th}>Comisión Calculada</th>
<th className={styles.th}>Ajustes Retroactivos</th>
<th className={styles.th}>Pago Total</th>
<th className={styles.th}>Estado</th>
</tr>
</thead>
<tbody>
{results.map((item, idx) => {
const colab = usersMap[item.main.userId] || { username: `ID: ${item.main.userId}` };
const plan = plansMap[item.main.planId] || { name: `ID: ${item.main.planId}`, version: 1 };
const achievementVal = (item.main.achievementPercentage * 100).toFixed(1) + '%';
const mainCommission = item.main.calculatedCommission + item.main.calculatedBonus;
return (
<tr key={idx} className={styles.tr}>
<td className={styles.td}>
<strong>{colab.username}</strong>
{colab.hotel && <div style={{ fontSize: '0.85rem', color: '#94a3b8' }}>{colab.hotel.code}</div>}
</td>
<td className={styles.td}>
{plan.name} (v{plan.version})
</td>
<td className={styles.td}>
${parseFloat(item.main.goalAmount.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
</td>
<td className={styles.td}>
${parseFloat(item.main.salesAmount.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
</td>
<td className={styles.td}>{achievementVal}</td>
<td className={styles.td}>
${mainCommission.toLocaleString('es-CO', { minimumFractionDigits: 2 })}
</td>
<td className={`${styles.td} ${item.main.adjustmentAmount < 0 ? styles.negative : item.main.adjustmentAmount > 0 ? styles.positive : ''}`}>
${parseFloat(item.main.adjustmentAmount.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
</td>
<td className={styles.td}>
<strong>
${parseFloat(item.main.totalPayout.toString()).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
</strong>
</td>
<td className={styles.td}>
<span
className={`${styles.badge} ${
item.main.status === 'SIMULATED'
? styles.badgeSimulated
: item.main.status === 'PENDING'
? styles.badgePending
: item.main.status === 'APPROVED'
? styles.badgeApproved
: styles.badgeRejected
}`}
>
{item.main.status}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</main>
</div>
);
}

View file

@ -0,0 +1,248 @@
.container {
min-height: 100vh;
background-color: #0f172a;
color: #f8fafc;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding-bottom: 3rem;
}
.mainContent {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.titleSection {
margin-bottom: 2.5rem;
}
.title {
font-size: 2.25rem;
font-weight: 800;
background: linear-gradient(135deg, #38bdf8, #818cf8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
}
.description {
color: #94a3b8;
font-size: 1rem;
}
.card {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.errorMsg {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.successMsg {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #86efac;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.tableWrapper {
overflow-x: auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
text-align: left;
}
.th {
background: #1e293b;
padding: 1rem 1.25rem;
font-weight: 600;
color: #94a3b8;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.tr {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background-color 0.2s ease;
}
.tr:hover {
background: rgba(255, 255, 255, 0.02);
}
.td {
padding: 1rem 1.25rem;
color: #cbd5e1;
vertical-align: middle;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 700;
border-radius: 4px;
}
.badgeSimulated {
background: rgba(56, 189, 248, 0.15);
color: #38bdf8;
}
.badgePending {
background: rgba(234, 179, 8, 0.15);
color: #eab308;
}
.badgeApproved {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.badgeRejected {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.actionsCell {
display: flex;
gap: 0.5rem;
}
.btnApprove {
background: #22c55e;
color: #fff;
font-weight: 600;
font-size: 0.85rem;
border: none;
border-radius: 6px;
padding: 0.4rem 0.8rem;
cursor: pointer;
transition: all 0.2s ease;
}
.btnApprove:hover {
background: #16a34a;
}
.btnReject {
background: #ef4444;
color: #fff;
font-weight: 600;
font-size: 0.85rem;
border: none;
border-radius: 6px;
padding: 0.4rem 0.8rem;
cursor: pointer;
transition: all 0.2s ease;
}
.btnReject:hover {
background: #dc2626;
}
/* Modal Styling */
.modalBackdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modalContent {
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
width: 90%;
max-width: 500px;
padding: 2rem;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
}
.modalTitle {
font-size: 1.25rem;
font-weight: 700;
color: #fff;
margin-bottom: 1rem;
}
.textarea {
width: 100%;
height: 120px;
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 0.75rem;
color: #fff;
font-size: 0.95rem;
outline: none;
resize: none;
margin-bottom: 1.5rem;
}
.textarea:focus {
border-color: #ef4444;
}
.modalActions {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.btnCancel {
background: #475569;
color: #fff;
font-weight: 600;
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.btnCancel:hover {
background: #334155;
}
.btnConfirmReject {
background: #ef4444;
color: #fff;
font-weight: 600;
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.btnConfirmReject:hover {
background: #dc2626;
}

View file

@ -0,0 +1,336 @@
'use strict';
'use client';
import React, { useState, useEffect } from 'react';
import styles from './page.module.css';
import Header from '@/components/Header';
import { useRouter } from 'next/navigation';
interface Settlement {
id: number;
period: string;
planId: number;
userId: number;
salesAmount: string;
goalAmount: string;
achievementPercentage: string;
calculatedCommission: string;
calculatedBonus: string;
adjustmentAmount: string;
totalPayout: string;
status: string;
rejectionReason: string | null;
colaborador: {
username: string;
email: string;
area: string;
hotel: {
name: string;
code: string;
};
};
plan: {
name: string;
code: string;
version: number;
};
}
export default function ApprovalsPage() {
const router = useRouter();
const [role, setRole] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [settlements, setSettlements] = useState<Settlement[]>([]);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// Modal State
const [showRejectModal, setShowRejectModal] = useState(false);
const [rejectId, setRejectId] = useState<number | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [isSubmittingReject, setIsSubmittingReject] = useState(false);
const fetchSettlements = async () => {
try {
const res = await fetch('/api/settlements');
if (res.ok) {
const data = await res.json();
setSettlements(data.settlements || []);
} else {
const data = await res.json();
setError(data.error || 'Failed to load settlements.');
}
} catch (err) {
console.error('Failed to fetch settlements:', err);
setError('Failed to connect to the server.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetch('/api/auth/me')
.then((res) => {
if (!res.ok) {
router.push('/login');
return null;
}
return res.json();
})
.then((data) => {
if (data && data.user) {
setRole(data.user.role);
if (
data.user.role !== 'commercial_leader' &&
data.user.role !== 'admin' &&
data.user.role !== 'analyst' &&
data.user.role !== 'director'
) {
setError('Acceso prohibido. Permisos insuficientes.');
setIsLoading(false);
} else {
fetchSettlements();
}
}
})
.catch((err) => {
console.error('Failed to get session user details', err);
router.push('/login');
});
}, []);
const handleApprove = async (id: number) => {
setError(null);
setSuccess(null);
try {
const res = await fetch(`/api/settlements/${id}/approve`, {
method: 'POST'
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Error al aprobar la liquidación.');
return;
}
setSuccess('Liquidación aprobada con éxito.');
fetchSettlements();
} catch (err) {
console.error('Failed to approve settlement:', err);
setError('Error al comunicarse con el servidor.');
}
};
const openRejectModal = (id: number) => {
setRejectId(id);
setRejectReason('');
setShowRejectModal(true);
};
const closeRejectModal = () => {
setShowRejectModal(false);
setRejectId(null);
setRejectReason('');
};
const handleRejectConfirm = async () => {
if (!rejectId) return;
if (!rejectReason || rejectReason.trim() === '') {
setError('La razón del rechazo es obligatoria.');
return;
}
setError(null);
setSuccess(null);
setIsSubmittingReject(true);
try {
const res = await fetch(`/api/settlements/${rejectId}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: rejectReason })
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Error al rechazar la liquidación.');
setIsSubmittingReject(false);
return;
}
setSuccess('Liquidación rechazada.');
closeRejectModal();
fetchSettlements();
} catch (err) {
console.error('Failed to reject settlement:', err);
setError('Error al comunicarse con el servidor.');
} finally {
setIsSubmittingReject(false);
}
};
if (isLoading) {
return (
<div className={styles.container}>
<Header activeTab="approvals" />
<div className={styles.mainContent}>
<p>Cargando panel de aprobaciones...</p>
</div>
</div>
);
}
const isLeader = role === 'commercial_leader';
return (
<div className={styles.container}>
<Header activeTab="approvals" />
<main className={styles.mainContent}>
<div className={styles.titleSection}>
<h1 className={styles.title}>Panel de Aprobaciones</h1>
<p className={styles.description}>
{isLeader
? 'Revise y apruebe las liquidaciones de comisión de los colaboradores de su región.'
: 'Visualice y audite el estado de aprobación de las liquidaciones de comisiones.'}
</p>
</div>
{error && <div className={styles.errorMsg} id="approvals-error-msg">{error}</div>}
{success && <div className={styles.successMsg} id="approvals-success-msg">{success}</div>}
<div className={styles.card}>
<div className={styles.tableWrapper}>
<table className={styles.table} id="approvals-table">
<thead>
<tr>
<th className={styles.th}>Colaborador</th>
<th className={styles.th}>Plan</th>
<th className={styles.th}>Período</th>
<th className={styles.th}>Meta</th>
<th className={styles.th}>Ventas</th>
<th className={styles.th}>Cumplimiento</th>
<th className={styles.th}>Pago Total</th>
<th className={styles.th}>Estado</th>
<th className={styles.th}>Acciones</th>
</tr>
</thead>
<tbody>
{settlements.length === 0 ? (
<tr>
<td colSpan={9} className={styles.td} style={{ textAlign: 'center' }}>
No se encontraron liquidaciones pendientes en su región.
</td>
</tr>
) : (
settlements.map((s) => {
const achievementVal = (parseFloat(s.achievementPercentage) * 100).toFixed(1) + '%';
const isPending = s.status === 'PENDING' || s.status === 'SIMULATED';
return (
<tr key={s.id} className={styles.tr}>
<td className={styles.td}>
<strong>{s.colaborador.username}</strong>
<div style={{ fontSize: '0.85rem', color: '#94a3b8' }}>
{s.colaborador.hotel.name} ({s.colaborador.hotel.code})
</div>
</td>
<td className={styles.td}>
{s.plan.name} (v{s.plan.version})
</td>
<td className={styles.td}>{s.period}</td>
<td className={styles.td}>
${parseFloat(s.goalAmount).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
</td>
<td className={styles.td}>
${parseFloat(s.salesAmount).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
</td>
<td className={styles.td}>{achievementVal}</td>
<td className={styles.td}>
<strong>
${parseFloat(s.totalPayout).toLocaleString('es-CO', { minimumFractionDigits: 2 })}
</strong>
</td>
<td className={styles.td}>
<span
className={`${styles.badge} ${
s.status === 'SIMULATED'
? styles.badgeSimulated
: s.status === 'PENDING'
? styles.badgePending
: s.status === 'APPROVED'
? styles.badgeApproved
: styles.badgeRejected
}`}
>
{s.status}
</span>
{s.rejectionReason && (
<div style={{ fontSize: '0.75rem', color: '#f87171', marginTop: '0.25rem' }}>
Razón: {s.rejectionReason}
</div>
)}
</td>
<td className={styles.td}>
{isPending && (role === 'commercial_leader' || role === 'admin') ? (
<div className={styles.actionsCell}>
<button
id={`btn-approve-${s.id}`}
className={styles.btnApprove}
onClick={() => handleApprove(s.id)}
>
Aprobar
</button>
<button
id={`btn-reject-${s.id}`}
className={styles.btnReject}
onClick={() => openRejectModal(s.id)}
>
Rechazar
</button>
</div>
) : (
<span style={{ fontSize: '0.85rem', color: '#64748b' }}>Sin acciones</span>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</main>
{showRejectModal && (
<div className={styles.modalBackdrop}>
<div className={styles.modalContent}>
<h2 className={styles.modalTitle}>Rechazar Liquidación</h2>
<textarea
id="reject-reason-input"
className={styles.textarea}
placeholder="Ingrese el motivo del rechazo..."
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
disabled={isSubmittingReject}
/>
<div className={styles.modalActions}>
<button
id="btn-cancel-reject"
className={styles.btnCancel}
onClick={closeRejectModal}
disabled={isSubmittingReject}
>
Cancelar
</button>
<button
id="btn-confirm-reject"
className={styles.btnConfirmReject}
onClick={handleRejectConfirm}
disabled={isSubmittingReject}
>
{isSubmittingReject ? 'Rechazando...' : 'Confirmar Rechazo'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation';
import styles from './Header.module.css';
interface HeaderProps {
activeTab: 'plans' | 'goals' | 'import' | 'none';
activeTab: 'plans' | 'goals' | 'import' | 'simulation' | 'approvals' | 'none';
}
export default function Header({ activeTab }: HeaderProps) {
@ -38,6 +38,8 @@ export default function Header({ activeTab }: HeaderProps) {
};
const showImportLink = role === 'admin' || role === 'analyst' || role === 'commercial_leader';
const showSimulationLink = role === 'admin' || role === 'analyst';
const showApprovalsLink = role === 'admin' || role === 'commercial_leader';
return (
<header className={styles.header}>
@ -66,6 +68,24 @@ export default function Header({ activeTab }: HeaderProps) {
Cargar Ventas
</Link>
)}
{showSimulationLink && (
<Link
href="/sales/simulation"
className={`${styles.navLink} ${activeTab === 'simulation' ? styles.navLinkActive : ''}`}
id="nav-simulation"
>
Simulación
</Link>
)}
{showApprovalsLink && (
<Link
href="/settlements/approvals"
className={`${styles.navLink} ${activeTab === 'approvals' ? styles.navLinkActive : ''}`}
id="nav-approvals"
>
Aprobaciones
</Link>
)}
</nav>
<button onClick={handleLogout} className={styles.logoutBtn}>
Cerrar Sesión
@ -73,3 +93,4 @@ export default function Header({ activeTab }: HeaderProps) {
</header>
);
}

46
src/lib/log-redactor.ts Normal file
View file

@ -0,0 +1,46 @@
export function redact(obj: any): any {
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'string') {
let str = obj;
const sensitiveKeys = ['password', 'passwordHash', 'baseSalary', 'bankAccount', 'salary'];
for (const key of sensitiveKeys) {
const regex = new RegExp(`("${key}"\\s*:\\s*")[^"]+(")`, 'gi');
str = str.replace(regex, `$1[REDACTED]$2`);
const eqRegex = new RegExp(`(${key}\\s*=\\s*)[^\\s&,]+`, 'gi');
str = str.replace(eqRegex, `$1[REDACTED]`);
}
return str;
}
if (Array.isArray(obj)) {
return obj.map(item => redact(item));
}
if (typeof obj === 'object') {
const newObj: any = {};
const sensitiveKeys = ['password', 'passwordHash', 'baseSalary', 'bankAccount', 'salary'];
for (const key of Object.keys(obj)) {
if (sensitiveKeys.includes(key)) {
newObj[key] = '[REDACTED]';
} else {
newObj[key] = redact(obj[key]);
}
}
return newObj;
}
return obj;
}
export function setupLogRedaction() {
if (typeof global !== 'undefined' && !(global as any).__logRedactionSetup) {
(global as any).__logRedactionSetup = true;
const originalLog = console.log;
const originalInfo = console.info;
const originalWarn = console.warn;
const originalError = console.error;
console.log = (...args: any[]) => originalLog(...args.map(redact));
console.info = (...args: any[]) => originalInfo(...args.map(redact));
console.warn = (...args: any[]) => originalWarn(...args.map(redact));
console.error = (...args: any[]) => originalError(...args.map(redact));
}
}

22
src/lib/n8n-auth.ts Normal file
View file

@ -0,0 +1,22 @@
import crypto from 'crypto';
function safeCompare(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) {
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
export function verifyN8nSignature(signature: string | null): boolean {
if (!signature) return false;
const secret = process.env.N8N_WEBHOOK_SECRET || '';
const secretDev = process.env.N8N_WEBHOOK_SECRET_DEV || '';
if (secret && safeCompare(signature, secret)) return true;
if (secretDev && safeCompare(signature, secretDev)) return true;
return false;
}