feat: BI-CCC evolution — 6-phase platform upgrade (45→85 maturity)
Phase 1: Refactor queries.js (1787 lines) into domain modules with facade pattern
- src/queries/{helpers,payin,payout,corporate,bi,client,provider,compliance}.queries.js
- New provider performance + compliance data layer queries
- Health check endpoint (GET /health)
Phase 2: Provider Performance Dashboard (src/admin-providers.js)
- Hero cards, sortable tables, Chart.js charts, date range filter
- API routes: /admin/api/providers, /admin/api/providers/failed, /admin/api/providers/trend
Phase 3: Excel Export (exceljs)
- CambioReal-branded exports for BI, clients, providers, transactions
- Export buttons added to BI and Client 360 dashboards
Phase 4: Alert System (node-cron + nodemailer)
- 5 alert rules: volume spike, spread anomaly, large tx, failed tx spike, provider inactivity
- SQLite alerts table, bell icon UI with acknowledge workflow
- Email notifications via SMTP
Phase 5: Enhanced Analytics
- Churn prediction: weighted RFM model (src/services/churn-predictor.js)
- Volume forecasting: exponential smoothing with confidence bands (src/services/forecast.js)
- Forecast chart in BI dashboard, churn risk in Client 360
Phase 6: SQLite Analytics Store (ETL)
- src/db-analytics.js: daily_metrics, client_health_daily, monthly_revenue tables
- src/etl/daily-sync.js: MySQL RDS → SQLite daily sync at 1 AM + 90-day backfill
- src/etl/data-quality.js: post-sync validation (row counts, reconciliation)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
670
package-lock.json
generated
670
package-lock.json
generated
@@ -11,9 +11,39 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"mysql2": "^3.12.0"
|
||||
"mysql2": "^3.12.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@fast-csv/format": {
|
||||
"version": "4.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz",
|
||||
"integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==",
|
||||
"dependencies": {
|
||||
"@types/node": "^14.0.1",
|
||||
"lodash.escaperegexp": "^4.1.2",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.isfunction": "^3.0.9",
|
||||
"lodash.isnil": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fast-csv/parse": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz",
|
||||
"integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==",
|
||||
"dependencies": {
|
||||
"@types/node": "^14.0.1",
|
||||
"lodash.escaperegexp": "^4.1.2",
|
||||
"lodash.groupby": "^4.6.0",
|
||||
"lodash.isfunction": "^3.0.9",
|
||||
"lodash.isnil": "^4.0.0",
|
||||
"lodash.isundefined": "^3.0.1",
|
||||
"lodash.uniq": "^4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
@@ -36,6 +66,11 @@
|
||||
"node-pre-gyp": "bin/node-pre-gyp"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "14.18.63",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz",
|
||||
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
@@ -105,6 +140,70 @@
|
||||
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/archiver": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz",
|
||||
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
|
||||
"dependencies": {
|
||||
"archiver-utils": "^2.1.0",
|
||||
"async": "^3.2.4",
|
||||
"buffer-crc32": "^0.2.1",
|
||||
"readable-stream": "^3.6.0",
|
||||
"readdir-glob": "^1.1.2",
|
||||
"tar-stream": "^2.2.0",
|
||||
"zip-stream": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.4",
|
||||
"graceful-fs": "^4.2.0",
|
||||
"lazystream": "^1.0.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.difference": "^4.5.0",
|
||||
"lodash.flatten": "^4.4.0",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.union": "^4.6.0",
|
||||
"normalize-path": "^3.0.0",
|
||||
"readable-stream": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/are-we-there-yet": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
|
||||
@@ -125,6 +224,11 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
@@ -185,6 +289,26 @@
|
||||
"prebuild-install": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/big-integer": {
|
||||
"version": "1.6.52",
|
||||
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
|
||||
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/binary": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
|
||||
"integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==",
|
||||
"dependencies": {
|
||||
"buffers": "~0.1.1",
|
||||
"chainsaw": "~0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
@@ -205,6 +329,11 @@
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bluebird": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
||||
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -263,6 +392,30 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-indexof-polyfill": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
|
||||
"integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/buffers": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
|
||||
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==",
|
||||
"engines": {
|
||||
"node": ">=0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -301,6 +454,17 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/chainsaw": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
|
||||
"integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==",
|
||||
"dependencies": {
|
||||
"traverse": ">=0.3.0 <0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||
@@ -319,6 +483,20 @@
|
||||
"color-support": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/compress-commons": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz",
|
||||
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "^0.2.13",
|
||||
"crc32-stream": "^4.0.2",
|
||||
"normalize-path": "^3.0.0",
|
||||
"readable-stream": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -367,6 +545,39 @@
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crc32-stream": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz",
|
||||
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
|
||||
"dependencies": {
|
||||
"crc-32": "^1.2.0",
|
||||
"readable-stream": "^3.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
@@ -469,6 +680,41 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer2": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer2/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer2/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/duplexer2/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -544,6 +790,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/exceljs": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
|
||||
"integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==",
|
||||
"dependencies": {
|
||||
"archiver": "^5.0.0",
|
||||
"dayjs": "^1.8.34",
|
||||
"fast-csv": "^4.3.1",
|
||||
"jszip": "^3.10.1",
|
||||
"readable-stream": "^3.6.0",
|
||||
"saxes": "^5.0.1",
|
||||
"tmp": "^0.2.0",
|
||||
"unzipper": "^0.10.11",
|
||||
"uuid": "^8.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
@@ -622,6 +887,18 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-csv": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz",
|
||||
"integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==",
|
||||
"dependencies": {
|
||||
"@fast-csv/format": "4.3.5",
|
||||
"@fast-csv/parse": "4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
@@ -700,6 +977,44 @@
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fstream": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz",
|
||||
"integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"inherits": "~2.0.0",
|
||||
"mkdirp": ">=0.5 0",
|
||||
"rimraf": "2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fstream/node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fstream/node_modules/rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -815,6 +1130,11 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -933,6 +1253,11 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
@@ -980,6 +1305,166 @@
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lazystream": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/lazystream/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lazystream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/lazystream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/listenercount": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz",
|
||||
"integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ=="
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
|
||||
},
|
||||
"node_modules/lodash.difference": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
|
||||
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="
|
||||
},
|
||||
"node_modules/lodash.escaperegexp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
|
||||
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="
|
||||
},
|
||||
"node_modules/lodash.flatten": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
||||
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="
|
||||
},
|
||||
"node_modules/lodash.groupby": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz",
|
||||
"integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead."
|
||||
},
|
||||
"node_modules/lodash.isfunction": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz",
|
||||
"integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw=="
|
||||
},
|
||||
"node_modules/lodash.isnil": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz",
|
||||
"integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng=="
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
|
||||
},
|
||||
"node_modules/lodash.isundefined": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz",
|
||||
"integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA=="
|
||||
},
|
||||
"node_modules/lodash.union": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
|
||||
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw=="
|
||||
},
|
||||
"node_modules/lodash.uniq": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
|
||||
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
@@ -1266,6 +1751,14 @@
|
||||
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
@@ -1286,6 +1779,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||
@@ -1301,6 +1802,14 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/npmlog": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
|
||||
@@ -1365,6 +1874,11 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -1415,6 +1929,11 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -1515,6 +2034,33 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdir-glob": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
||||
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||
"dependencies": {
|
||||
"minimatch": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdir-glob/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
@@ -1557,6 +2103,17 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz",
|
||||
"integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
@@ -1625,6 +2182,11 @@
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -1868,6 +2430,14 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -1883,6 +2453,14 @@
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/traverse": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
|
||||
"integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
@@ -1929,6 +2507,50 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/unzipper": {
|
||||
"version": "0.10.14",
|
||||
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz",
|
||||
"integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==",
|
||||
"dependencies": {
|
||||
"big-integer": "^1.6.17",
|
||||
"binary": "~0.3.0",
|
||||
"bluebird": "~3.4.1",
|
||||
"buffer-indexof-polyfill": "~1.0.0",
|
||||
"duplexer2": "~0.1.4",
|
||||
"fstream": "^1.0.12",
|
||||
"graceful-fs": "^4.2.2",
|
||||
"listenercount": "~1.0.1",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "~1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/unzipper/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/unzipper/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/unzipper/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -1944,6 +2566,14 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -1984,11 +2614,49 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/zip-stream": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
|
||||
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
|
||||
"dependencies": {
|
||||
"archiver-utils": "^3.0.4",
|
||||
"compress-commons": "^4.1.2",
|
||||
"readable-stream": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/zip-stream/node_modules/archiver-utils": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz",
|
||||
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
|
||||
"dependencies": {
|
||||
"glob": "^7.2.3",
|
||||
"graceful-fs": "^4.2.0",
|
||||
"lazystream": "^1.0.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.difference": "^4.5.0",
|
||||
"lodash.flatten": "^4.4.0",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.union": "^4.6.0",
|
||||
"normalize-path": "^3.0.0",
|
||||
"readable-stream": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"mysql2": "^3.12.0"
|
||||
"mysql2": "^3.12.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
377
server.js
377
server.js
@@ -11,13 +11,20 @@ const express = require('express');
|
||||
const session = require('express-session');
|
||||
const path = require('path');
|
||||
const { authenticate, requireAuth, requireRole, createAgente, createUser } = require('./src/auth');
|
||||
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics, fetchBIStrategic, fetchTopClients, fetchClientSearch, fetchClientProfile, fetchClientData, fetchMerchantProfile, fetchMerchantData } = require('./src/queries');
|
||||
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics, fetchBIStrategic, fetchTopClients, fetchClientSearch, fetchClientProfile, fetchClientData, fetchMerchantProfile, fetchMerchantData, fetchProviderPerformance, fetchFailedTransactions, fetchProviderTrend } = require('./src/queries');
|
||||
const { buildAdminProvidersHTML } = require('./src/admin-providers');
|
||||
const pool = require('./src/db-rds');
|
||||
const { buildHTML } = require('./src/dashboard');
|
||||
const { buildAdminHTML } = require('./src/admin-panel');
|
||||
const { buildAdminHomeHTML } = require('./src/admin-home');
|
||||
const { buildAdminDashboardHTML } = require('./src/admin-dashboard');
|
||||
const { buildAdminBIHTML } = require('./src/admin-bi');
|
||||
const { buildAdminClienteHTML } = require('./src/admin-cliente');
|
||||
const { exportToExcel, createWorkbook, sendWorkbook } = require('./src/export/excel-export');
|
||||
const { startAlertEngine, getAlerts, acknowledgeAlert, getAlertHistory, getUnackedCount } = require('./src/alerts/alert-engine');
|
||||
const { predictChurnRisk } = require('./src/services/churn-predictor');
|
||||
const { forecastFromTrend } = require('./src/services/forecast');
|
||||
const { startETL } = require('./src/etl/daily-sync');
|
||||
const bcrypt = require('bcrypt');
|
||||
const db = require('./src/db-local');
|
||||
const cache = require('./src/cache');
|
||||
@@ -580,6 +587,368 @@ app.delete('/admin/agentes/:id', requireRole('admin'), (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Excel Export Endpoints ---
|
||||
|
||||
app.get('/admin/api/export/bi-excel', requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const { start, end } = req.query;
|
||||
if (!start || !end) return res.status(400).json({ error: 'start and end required' });
|
||||
const getAgenteName = (agenteId) => {
|
||||
const row = db.prepare('SELECT nome FROM agentes WHERE agente_id = ?').get(agenteId);
|
||||
return row ? row.nome : null;
|
||||
};
|
||||
const data = await fetchBIData(start, end, getAgenteName);
|
||||
|
||||
// Build multi-sheet workbook
|
||||
const ExcelJS = require('exceljs');
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'CambioReal BI-CCC';
|
||||
|
||||
// Sheet 1: KPI Summary
|
||||
const kpiSheet = workbook.addWorksheet('KPI Summary');
|
||||
kpiSheet.columns = [
|
||||
{ header: 'Metric', key: 'metric', width: 30 },
|
||||
{ header: 'Current', key: 'current', width: 18 },
|
||||
{ header: 'Previous', key: 'previous', width: 18 },
|
||||
{ header: 'Change %', key: 'change', width: 14 }
|
||||
];
|
||||
const k = data.kpis.total;
|
||||
const c = data.comparison;
|
||||
const pctChg = (curr, prev) => prev > 0 ? Math.round((curr - prev) / prev * 100) : 0;
|
||||
kpiSheet.addRows([
|
||||
{ metric: 'Total Transactions', current: k.qtd, previous: c.prev_qtd, change: pctChg(k.qtd, c.prev_qtd) },
|
||||
{ metric: 'Total Volume USD', current: k.vol_usd, previous: c.prev_vol_usd, change: pctChg(k.vol_usd, c.prev_vol_usd) },
|
||||
{ metric: 'Spread Revenue', current: k.spread_revenue, previous: c.prev_spread, change: pctChg(k.spread_revenue, c.prev_spread) },
|
||||
{ metric: 'Active Clients', current: k.clientes, previous: '-', change: '-' },
|
||||
{ metric: 'Avg Ticket', current: k.ticket_medio, previous: '-', change: '-' },
|
||||
{ metric: 'Retention Rate %', current: data.retention.rate, previous: '-', change: '-' }
|
||||
]);
|
||||
|
||||
// Sheet 2: Top Clients
|
||||
const clientSheet = workbook.addWorksheet('Top Clients');
|
||||
clientSheet.columns = [
|
||||
{ header: 'Client', key: 'nome', width: 30 },
|
||||
{ header: 'Volume USD', key: 'vol_usd', width: 18 },
|
||||
{ header: 'Transactions', key: 'qtd', width: 14 }
|
||||
];
|
||||
data.topClients.forEach(c => clientSheet.addRow(c));
|
||||
|
||||
// Sheet 3: Agent Ranking
|
||||
const agentSheet = workbook.addWorksheet('Agent Ranking');
|
||||
agentSheet.columns = [
|
||||
{ header: 'Rank', key: 'rank', width: 8 },
|
||||
{ header: 'Agent', key: 'nome', width: 25 },
|
||||
{ header: 'Volume USD', key: 'vol_usd', width: 18 },
|
||||
{ header: 'Transactions', key: 'qtd', width: 14 },
|
||||
{ header: 'Spread Revenue', key: 'spread_revenue', width: 18 },
|
||||
{ header: 'Clients', key: 'clientes', width: 12 }
|
||||
];
|
||||
data.agentRanking.forEach(a => agentSheet.addRow(a));
|
||||
|
||||
// Sheet 4: Clients at Risk
|
||||
const riskSheet = workbook.addWorksheet('Clients at Risk');
|
||||
riskSheet.columns = [
|
||||
{ header: 'Client', key: 'nome', width: 30 },
|
||||
{ header: 'Volume USD', key: 'vol_usd', width: 18 },
|
||||
{ header: 'Transactions', key: 'qtd', width: 14 },
|
||||
{ header: 'Last Activity', key: 'last_op', width: 18 },
|
||||
{ header: 'Days Inactive', key: 'days_inactive', width: 14 }
|
||||
];
|
||||
data.clientsAtRisk.forEach(c => riskSheet.addRow(c));
|
||||
|
||||
// Style all sheet headers
|
||||
workbook.eachSheet(sheet => {
|
||||
const hr = sheet.getRow(1);
|
||||
hr.eachCell(cell => {
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF7600BE' } };
|
||||
cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 };
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
});
|
||||
hr.height = 28;
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
});
|
||||
|
||||
await sendWorkbook(res, workbook, `BI_Executive_${start}_${end}`);
|
||||
} catch (err) {
|
||||
console.error('BI Export error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/api/export/clients-excel', requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const clients = await cache.getOrFetch('top-clients', fetchTopClients, 15 * 60 * 1000);
|
||||
await exportToExcel(res, clients, [
|
||||
{ header: 'Client', key: 'nome', width: 30 },
|
||||
{ header: 'Volume USD', key: 'vol', width: 18, type: 'currency' },
|
||||
{ header: 'Operations', key: 'ops', width: 14, type: 'number' },
|
||||
{ header: 'Months Active', key: 'months', width: 14, type: 'number' },
|
||||
{ header: 'Last Activity', key: 'lastOp', width: 16, type: 'date' }
|
||||
], 'Top Clients', `Top_Clients_${new Date().toISOString().slice(0, 10)}`);
|
||||
} catch (err) {
|
||||
console.error('Clients Export error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/api/export/providers-excel', requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const { start, end } = req.query;
|
||||
if (!start || !end) return res.status(400).json({ error: 'start and end required' });
|
||||
const data = await fetchProviderPerformance(start, end);
|
||||
await exportToExcel(res, data.providers, [
|
||||
{ header: 'Provider', key: 'provider', width: 18 },
|
||||
{ header: 'Flow', key: 'flow', width: 12 },
|
||||
{ header: 'Total Tx', key: 'total_tx', width: 12, type: 'number' },
|
||||
{ header: 'Success Tx', key: 'success_tx', width: 12, type: 'number' },
|
||||
{ header: 'Success Rate %', key: 'success_rate', width: 14, type: 'percentage' },
|
||||
{ header: 'Volume USD', key: 'vol_usd', width: 18, type: 'currency' },
|
||||
{ header: 'Avg Ticket', key: 'avg_ticket', width: 14, type: 'currency' },
|
||||
{ header: 'Spread %', key: 'avg_spread_pct', width: 12, type: 'percentage' },
|
||||
{ header: 'Settlement Hours', key: 'avg_settlement_hours', width: 16, type: 'number' }
|
||||
], 'Providers', `Providers_${start}_${end}`);
|
||||
} catch (err) {
|
||||
console.error('Providers Export error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/api/export/transactions-excel', requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const { start, end } = req.query;
|
||||
const dias = start && end ? null : 90;
|
||||
let data;
|
||||
if (start && end) {
|
||||
// Use fetchBIData for date-filtered transactions
|
||||
const biData = await fetchBIData(start, end);
|
||||
// Combine trend data into flat rows
|
||||
const allTrend = [];
|
||||
['brlUsd', 'usdBrl', 'usdUsd'].forEach(flow => {
|
||||
(biData.trend[flow] || []).forEach(r => {
|
||||
allTrend.push({ date: r.dia, flow, transactions: r.qtd, volume_usd: r.vol_usd, avg_spread: r.avg_spread || 0 });
|
||||
});
|
||||
});
|
||||
data = allTrend;
|
||||
} else {
|
||||
const raw = await fetchAllTransacoes(dias || 90);
|
||||
data = serialize(raw.rowsBrlUsd, raw.rowsUsdBrl);
|
||||
}
|
||||
|
||||
await exportToExcel(res, data, data.length > 0 && data[0].fluxo ? [
|
||||
{ header: 'Flow', key: 'fluxo', width: 12 },
|
||||
{ header: 'Client', key: 'cliente', width: 28 },
|
||||
{ header: 'Date', key: 'data_operacao', width: 18 },
|
||||
{ header: 'BRL', key: 'valor_reais', width: 14, type: 'currency' },
|
||||
{ header: 'USD', key: 'valor_dolar', width: 14, type: 'currency' },
|
||||
{ header: 'PTAX', key: 'taxa_ptax', width: 12 },
|
||||
{ header: 'Rate', key: 'taxa_cobrada', width: 12 },
|
||||
{ header: 'Spread', key: 'spread_bruto', width: 12 },
|
||||
{ header: 'Spread %', key: 'spread_pct', width: 10, type: 'percentage' },
|
||||
{ header: 'IOF %', key: 'iof_pct', width: 8 },
|
||||
{ header: 'Status', key: 'status', width: 14 }
|
||||
] : [
|
||||
{ header: 'Date', key: 'date', width: 14 },
|
||||
{ header: 'Flow', key: 'flow', width: 12 },
|
||||
{ header: 'Transactions', key: 'transactions', width: 14, type: 'number' },
|
||||
{ header: 'Volume USD', key: 'volume_usd', width: 16, type: 'currency' },
|
||||
{ header: 'Avg Spread %', key: 'avg_spread', width: 14, type: 'percentage' }
|
||||
], 'Transactions', `Transactions_${start || 'last90d'}_${end || 'today'}`);
|
||||
} catch (err) {
|
||||
console.error('Transactions Export error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Forecast API ---
|
||||
app.get('/admin/api/bi/forecast', requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const metric = req.query.metric || 'volume';
|
||||
const days = parseInt(req.query.days) || 30;
|
||||
// Get last 90 days of trend data for forecasting
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 90 * 86400000).toISOString().slice(0, 10);
|
||||
const end = now.toISOString().slice(0, 10);
|
||||
const biData = await fetchBIData(start, end);
|
||||
|
||||
// Combine all flows into daily totals
|
||||
const dailyMap = {};
|
||||
['brlUsd', 'usdBrl', 'usdUsd'].forEach(flow => {
|
||||
(biData.trend[flow] || []).forEach(d => {
|
||||
if (!dailyMap[d.dia]) dailyMap[d.dia] = { dia: d.dia, vol_usd: 0, qtd: 0 };
|
||||
dailyMap[d.dia].vol_usd += d.vol_usd;
|
||||
dailyMap[d.dia].qtd += d.qtd;
|
||||
});
|
||||
});
|
||||
const trendData = Object.values(dailyMap).sort((a, b) => a.dia.localeCompare(b.dia));
|
||||
|
||||
const metricKey = metric === 'transactions' ? 'qtd' : 'vol_usd';
|
||||
const result = forecastFromTrend(trendData, metricKey, days);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('Forecast API error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Churn Risk API ---
|
||||
app.get('/admin/api/cliente/:id/churn', requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const clienteId = parseInt(req.params.id);
|
||||
if (!clienteId) return res.status(400).json({ error: 'Invalid client ID' });
|
||||
|
||||
// Get profile and recent data
|
||||
const profile = await fetchClientProfile(clienteId);
|
||||
const now = new Date();
|
||||
const end = now.toISOString().slice(0, 10);
|
||||
const start30 = new Date(now.getTime() - 30 * 86400000).toISOString().slice(0, 10);
|
||||
const start60 = new Date(now.getTime() - 60 * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
const clientData = await fetchClientData(clienteId, start60, end);
|
||||
|
||||
// Count operations in current vs previous 30-day windows
|
||||
const currOps = (clientData.trend.brlUsd || [])
|
||||
.filter(d => d.dia >= start30)
|
||||
.reduce((s, d) => s + d.qtd, 0) +
|
||||
(clientData.trend.usdBrl || [])
|
||||
.filter(d => d.dia >= start30)
|
||||
.reduce((s, d) => s + d.qtd, 0);
|
||||
|
||||
const prevOps = (clientData.trend.brlUsd || [])
|
||||
.filter(d => d.dia < start30 && d.dia >= start60)
|
||||
.reduce((s, d) => s + d.qtd, 0) +
|
||||
(clientData.trend.usdBrl || [])
|
||||
.filter(d => d.dia < start30 && d.dia >= start60)
|
||||
.reduce((s, d) => s + d.qtd, 0);
|
||||
|
||||
// Determine product count
|
||||
let productCount = 0;
|
||||
if (profile.brlUsd && profile.brlUsd.qtd > 0) productCount++;
|
||||
if (profile.usdBrl && profile.usdBrl.qtd > 0) productCount++;
|
||||
|
||||
const churn = predictChurnRisk({
|
||||
days_inactive: profile.days_inactive,
|
||||
avg_monthly_ops: profile.avg_monthly_ops,
|
||||
avg_monthly_vol: profile.avg_monthly_vol,
|
||||
months_active: profile.months_active,
|
||||
curr_ops: currOps,
|
||||
prev_ops: prevOps,
|
||||
product_count: productCount
|
||||
});
|
||||
|
||||
res.json(churn);
|
||||
} catch (err) {
|
||||
console.error('Churn API error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Alert API Endpoints ---
|
||||
|
||||
app.get('/admin/api/alerts', requireRole('admin'), (req, res) => {
|
||||
try {
|
||||
const unacked = req.query.unacked === '1';
|
||||
const alerts = getAlerts(24, unacked);
|
||||
res.json({ alerts, unacked_count: getUnackedCount() });
|
||||
} catch (err) {
|
||||
console.error('Alerts API error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/admin/api/alerts/:id/ack', requireRole('admin'), (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
acknowledgeAlert(id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Alert ack error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/api/alerts/history', requireRole('admin'), (req, res) => {
|
||||
try {
|
||||
const days = parseInt(req.query.days) || 7;
|
||||
const alerts = getAlertHistory(days);
|
||||
res.json({ alerts });
|
||||
} catch (err) {
|
||||
console.error('Alert history error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Health Check ---
|
||||
app.get('/health', async (req, res) => {
|
||||
const health = {
|
||||
status: 'ok',
|
||||
uptime: Math.round(process.uptime()),
|
||||
memory: {
|
||||
rss: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
||||
heap: Math.round(process.memoryUsage().heapUsed / 1024 / 1024)
|
||||
},
|
||||
cache: cache.stats(),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
try {
|
||||
const conn = await pool.getConnection();
|
||||
await conn.execute('SELECT 1');
|
||||
conn.release();
|
||||
health.mysql = 'connected';
|
||||
} catch (err) {
|
||||
health.mysql = 'error: ' + err.message;
|
||||
health.status = 'degraded';
|
||||
}
|
||||
res.json(health);
|
||||
});
|
||||
|
||||
// --- Provider Dashboard (admin only) ---
|
||||
app.get('/admin/providers', requireRole('admin'), (req, res) => {
|
||||
try {
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
const html = buildAdminProvidersHTML(req.session.user);
|
||||
res.send(html);
|
||||
} catch (err) {
|
||||
console.error('Admin Providers error:', err);
|
||||
res.status(500).send('Erro ao carregar providers: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/api/providers', requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const { start, end } = req.query;
|
||||
if (!start || !end) return res.status(400).json({ error: 'start and end required' });
|
||||
const data = await fetchProviderPerformance(start, end);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.error('Provider API error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/api/providers/failed', requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const { start, end } = req.query;
|
||||
if (!start || !end) return res.status(400).json({ error: 'start and end required' });
|
||||
const data = await fetchFailedTransactions(start, end);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.error('Failed TX API error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/api/providers/trend', requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const { start, end } = req.query;
|
||||
if (!start || !end) return res.status(400).json({ error: 'start and end required' });
|
||||
const data = await fetchProviderTrend(start, end);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.error('Provider Trend API error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Start
|
||||
app.listen(PORT, () => {
|
||||
console.log(`BI - CCC rodando: http://localhost:${PORT}`);
|
||||
@@ -591,4 +960,10 @@ app.listen(PORT, () => {
|
||||
cache.registerAutoRefresh('top-agentes-30', () => fetchTopAgentes(30), 10 * 60 * 1000);
|
||||
cache.registerAutoRefresh('top-agentes-7', () => fetchTopAgentes(7), 10 * 60 * 1000);
|
||||
cache.registerAutoRefresh('top-agentes-90', () => fetchTopAgentes(90), 10 * 60 * 1000);
|
||||
|
||||
// Start alert engine
|
||||
startAlertEngine();
|
||||
|
||||
// Start ETL daily sync (MySQL RDS → SQLite analytics)
|
||||
startETL();
|
||||
});
|
||||
|
||||
@@ -410,6 +410,13 @@ function buildAdminBIHTML(user) {
|
||||
margin-left: auto; font-size: 12px; color: var(--text-muted);
|
||||
font-weight: 500; background: var(--bg); padding: 6px 12px; border-radius: 6px;
|
||||
}
|
||||
.export-btn {
|
||||
background: var(--green); color: white; border: none; padding: 8px 16px;
|
||||
border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
white-space: nowrap; transition: all 0.15s;
|
||||
}
|
||||
.export-btn:hover { opacity: 0.85; transform: translateY(-1px); }
|
||||
[data-theme="dark"] .export-btn { background: rgba(0,255,136,0.15); color: #00FF88; border: 1px solid rgba(0,255,136,0.3); }
|
||||
|
||||
/* Hero KPI Cards */
|
||||
.hero-grid {
|
||||
@@ -840,6 +847,7 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
|
||||
<input type="date" id="dateEnd" value="${today}">
|
||||
</div>
|
||||
<span class="period-info" id="periodInfo">Carregando...</span>
|
||||
<button class="export-btn" onclick="exportBIExcel()" title="Export to Excel">Export Excel</button>
|
||||
</div>
|
||||
|
||||
<!-- Hero KPI Cards -->
|
||||
@@ -954,6 +962,19 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Volume Forecast -->
|
||||
<div class="section-title" id="sectionForecast">
|
||||
<span class="icon">📈</span>
|
||||
Volume Forecast (30 Days)
|
||||
</div>
|
||||
<div class="chart-card" style="margin-bottom:28px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||
<h3 style="margin:0;">Forecast: Historical + Predicted Volume</h3>
|
||||
<span class="period-info" id="forecastInfo">Loading forecast...</span>
|
||||
</div>
|
||||
<div style="height:300px;"><canvas id="forecastChart"></canvas></div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Netting & Balanco -->
|
||||
<div class="section-title" id="sectionNetting">
|
||||
<span class="icon">⚖</span>
|
||||
@@ -2093,6 +2114,62 @@ function renderCohort(cohorts, t) {
|
||||
update();
|
||||
})();
|
||||
|
||||
var _forecastChart = null;
|
||||
async function loadForecast() {
|
||||
try {
|
||||
var resp = await fetch('/admin/api/bi/forecast?metric=volume&days=30');
|
||||
var data = await resp.json();
|
||||
if (!data.historical || data.historical.length === 0) {
|
||||
document.getElementById('forecastInfo').textContent = 'No data';
|
||||
return;
|
||||
}
|
||||
var theme = getChartTheme();
|
||||
var histLabels = data.historical.map(function(d){return d.dia;});
|
||||
var predLabels = data.predicted.map(function(d){return d.dia;});
|
||||
var allLabels = histLabels.concat(predLabels);
|
||||
var histValues = data.historical.map(function(d){return d.vol_usd;});
|
||||
var predValues = new Array(histLabels.length).fill(null).concat(data.predicted.map(function(d){return d.vol_usd;}));
|
||||
var upperValues = new Array(histLabels.length).fill(null).concat(data.confidence_upper.map(function(d){return d.vol_usd;}));
|
||||
var lowerValues = new Array(histLabels.length).fill(null).concat(data.confidence_lower.map(function(d){return d.vol_usd;}));
|
||||
// Extend historical with nulls for prediction period
|
||||
var histFull = histValues.concat(new Array(predLabels.length).fill(null));
|
||||
|
||||
if (_forecastChart) _forecastChart.destroy();
|
||||
var ctx = document.getElementById('forecastChart');
|
||||
if (!ctx) return;
|
||||
_forecastChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: allLabels,
|
||||
datasets: [
|
||||
{ label: 'Historical', data: histFull, borderColor: theme.blue, backgroundColor: 'transparent', borderWidth: 2, pointRadius: 0, tension: 0.3 },
|
||||
{ label: 'Forecast', data: predValues, borderColor: theme.green, backgroundColor: 'transparent', borderWidth: 2, borderDash: [6,3], pointRadius: 0, tension: 0.3 },
|
||||
{ label: 'Upper 95%', data: upperValues, borderColor: 'transparent', backgroundColor: theme.green + '15', fill: '+1', pointRadius: 0 },
|
||||
{ label: 'Lower 95%', data: lowerValues, borderColor: 'transparent', backgroundColor: 'transparent', fill: false, pointRadius: 0 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { color: theme.text, usePointStyle: true, pointStyle: 'line', font: { size: 11 } } } },
|
||||
scales: {
|
||||
x: { ticks: { color: theme.text, maxTicksLimit: 15, font: { size: 10 } }, grid: { color: theme.grid } },
|
||||
y: { ticks: { color: theme.text, callback: function(v){return '$' + (v>=1000 ? Math.round(v/1000)+'K' : v);} }, grid: { color: theme.grid } }
|
||||
}
|
||||
}
|
||||
});
|
||||
document.getElementById('forecastInfo').textContent = data.predicted.length + '-day forecast';
|
||||
} catch (e) {
|
||||
console.error('Forecast error:', e);
|
||||
document.getElementById('forecastInfo').textContent = 'Forecast error';
|
||||
}
|
||||
}
|
||||
|
||||
function exportBIExcel() {
|
||||
var s = document.getElementById('dateStart').value;
|
||||
var e = document.getElementById('dateEnd').value;
|
||||
if (s && e) window.location.href = '/admin/api/export/bi-excel?start=' + s + '&end=' + e;
|
||||
}
|
||||
|
||||
// Init - runs immediately (script is at bottom of body, DOM is ready)
|
||||
// Also handles case where DOMContentLoaded already fired
|
||||
function _startBI() {
|
||||
@@ -2104,7 +2181,7 @@ function _startBI() {
|
||||
// Apply theme-aware Chart.js defaults
|
||||
applyChartDefaults(getChartTheme());
|
||||
|
||||
loadBI(); loadRevenue(); loadStrategic(); fetchLiveRate();
|
||||
loadBI(); loadRevenue(); loadStrategic(); loadForecast(); fetchLiveRate();
|
||||
setInterval(fetchLiveRate, 3000);
|
||||
|
||||
// Re-render charts on theme toggle
|
||||
|
||||
@@ -205,6 +205,9 @@ function buildAdminClienteHTML(user) {
|
||||
.date-inputs label { font-size: 12px; font-weight: 600; color: var(--text-muted); }
|
||||
.date-inputs input[type="date"] { padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 13px; font-family: inherit; background: var(--bg); color: var(--text); }
|
||||
.period-info { margin-left: auto; font-size: 12px; color: var(--text-muted); font-weight: 500; background: var(--bg); padding: 6px 12px; border-radius: 6px; }
|
||||
.export-btn { background: var(--green); color: white; border: none; padding: 8px 16px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; white-space: nowrap; transition: all 0.15s; }
|
||||
.export-btn:hover { opacity: 0.85; transform: translateY(-1px); }
|
||||
[data-theme="dark"] .export-btn { background: rgba(0,255,136,0.15); color: #00FF88; border: 1px solid rgba(0,255,136,0.3); }
|
||||
|
||||
/* === Hero KPIs === */
|
||||
.hero-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px; margin-bottom: 28px; }
|
||||
@@ -413,6 +416,7 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'cliente' })}
|
||||
<div class="health-score-number" id="healthScoreNum">--</div>
|
||||
<div class="health-score-label" id="healthScoreLabel">Health</div>
|
||||
</div>
|
||||
<div id="churnRisk" style="padding:0 16px 16px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Date Filter -->
|
||||
@@ -431,6 +435,7 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'cliente' })}
|
||||
<label>Ate:</label><input type="date" id="dateEnd" value="${today}">
|
||||
</div>
|
||||
<span class="period-info" id="periodInfo">--</span>
|
||||
<button class="export-btn" onclick="window.location.href='/admin/api/export/clients-excel'" title="Export Top Clients to Excel">Export Excel</button>
|
||||
</div>
|
||||
|
||||
<!-- Hero KPIs (6) -->
|
||||
@@ -904,6 +909,10 @@ function clearClient() {
|
||||
function loadProfile() {
|
||||
fetch('/admin/api/cliente/' + selectedClientId + '/profile').then(function(r){return r.json();}).then(function(data) {
|
||||
profileData = data; renderProfile(data);
|
||||
// Load churn risk
|
||||
fetch('/admin/api/cliente/' + selectedClientId + '/churn').then(function(r){return r.json();}).then(function(churn) {
|
||||
renderChurnRisk(churn);
|
||||
}).catch(function(){});
|
||||
});
|
||||
}
|
||||
function renderProfile(p) {
|
||||
@@ -932,6 +941,23 @@ function renderProfile(p) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderChurnRisk(churn) {
|
||||
var el = document.getElementById('churnRisk');
|
||||
if (!el) return;
|
||||
var colors = { low: 'var(--green)', medium: 'var(--orange)', high: 'var(--red)', critical: 'var(--red)' };
|
||||
var labels = { low: 'Low Risk', medium: 'Medium Risk', high: 'High Risk', critical: 'Critical' };
|
||||
el.innerHTML = '<div style="display:flex;align-items:center;gap:8px;margin-top:8px;">' +
|
||||
'<div style="width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;' +
|
||||
'background:' + colors[churn.risk] + '20;color:' + colors[churn.risk] + ';font-weight:700;font-size:14px;">' + churn.score + '</div>' +
|
||||
'<div><div style="font-size:12px;font-weight:600;color:' + colors[churn.risk] + '">' + labels[churn.risk] + '</div>' +
|
||||
'<div style="font-size:10px;color:var(--text-muted)">Health: ' + churn.health_score + '/100</div></div></div>' +
|
||||
'<div style="margin-top:6px;font-size:10px;color:var(--text-muted)">' +
|
||||
(churn.factors || []).slice(0,3).map(function(f){
|
||||
var ic = f.status === 'good' ? '✅' : f.status === 'warning' ? '⚠' : '❌';
|
||||
return ic + ' ' + f.name + ': ' + f.score + '/100';
|
||||
}).join(' ') + '</div>';
|
||||
}
|
||||
|
||||
// === Data Loading ===
|
||||
function loadData() {
|
||||
if (!selectedClientId) return;
|
||||
|
||||
1074
src/admin-providers.js
Normal file
1074
src/admin-providers.js
Normal file
File diff suppressed because it is too large
Load Diff
279
src/alerts/alert-engine.js
Normal file
279
src/alerts/alert-engine.js
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Alert Engine — Monitors key metrics and triggers alerts
|
||||
* Uses node-cron for scheduling, SQLite for storage, email for notifications
|
||||
*/
|
||||
const cron = require('node-cron');
|
||||
const db = require('../db-local');
|
||||
const pool = require('../db-rds');
|
||||
const { sendEmail } = require('./channels');
|
||||
|
||||
// Insert alert into SQLite
|
||||
function createAlert(name, severity, message, data = null) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO alerts (name, severity, message, data)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
const result = stmt.run(name, severity, message, data ? JSON.stringify(data) : null);
|
||||
console.log(`[Alert] ${severity} - ${name}: ${message}`);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
// Get recent alerts
|
||||
function getAlerts(hours = 24, unackedOnly = false) {
|
||||
let sql = `SELECT * FROM alerts WHERE created_at >= datetime('now', '-${Math.round(hours)} hours')`;
|
||||
if (unackedOnly) sql += ' AND acknowledged = 0';
|
||||
sql += ' ORDER BY created_at DESC';
|
||||
return db.prepare(sql).all();
|
||||
}
|
||||
|
||||
// Acknowledge alert
|
||||
function acknowledgeAlert(id) {
|
||||
return db.prepare('UPDATE alerts SET acknowledged = 1 WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// Get alert history
|
||||
function getAlertHistory(days = 7) {
|
||||
return db.prepare(`
|
||||
SELECT * FROM alerts
|
||||
WHERE created_at >= datetime('now', '-${Math.round(days)} days')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
`).all();
|
||||
}
|
||||
|
||||
// Count unacknowledged alerts
|
||||
function getUnackedCount() {
|
||||
const row = db.prepare("SELECT COUNT(*) as count FROM alerts WHERE acknowledged = 0 AND created_at >= datetime('now', '-24 hours')").get();
|
||||
return row?.count || 0;
|
||||
}
|
||||
|
||||
// --- Alert Rules ---
|
||||
|
||||
const alertRules = [
|
||||
{
|
||||
name: 'volume_spike',
|
||||
schedule: '*/15 * * * *', // Every 15 minutes
|
||||
severity: 'P1',
|
||||
async check() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
// Compare today's volume to 7-day average
|
||||
const [today] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd), 0), 2) as vol
|
||||
FROM br_transaction_to_usa WHERE DATE(created_at) = CURDATE()
|
||||
`);
|
||||
const [avg7] = await conn.execute(`
|
||||
SELECT COUNT(*) / 7.0 as avg_qtd, ROUND(COALESCE(SUM(amount_usd), 0) / 7.0, 2) as avg_vol
|
||||
FROM br_transaction_to_usa
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) AND DATE(created_at) < CURDATE()
|
||||
`);
|
||||
const todayVol = Number(today[0]?.vol) || 0;
|
||||
const avgVol = Number(avg7[0]?.avg_vol) || 0;
|
||||
if (avgVol > 0 && todayVol > avgVol * 2) {
|
||||
return {
|
||||
triggered: true,
|
||||
message: `Volume spike: $${Math.round(todayVol).toLocaleString()} today vs $${Math.round(avgVol).toLocaleString()} avg (${Math.round(todayVol / avgVol * 100)}% of avg)`,
|
||||
data: { todayVol, avgVol, ratio: Math.round(todayVol / avgVol * 100) }
|
||||
};
|
||||
}
|
||||
return { triggered: false };
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'spread_anomaly',
|
||||
schedule: '0 */2 * * *', // Every 2 hours
|
||||
severity: 'P2',
|
||||
async check() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [today] = await conn.execute(`
|
||||
SELECT ROUND(AVG((exchange_rate - ptax) / exchange_rate * 100), 2) as avg_spread
|
||||
FROM br_transaction_to_usa WHERE DATE(created_at) = CURDATE() AND ptax > 0
|
||||
`);
|
||||
const [avg30] = await conn.execute(`
|
||||
SELECT ROUND(AVG((exchange_rate - ptax) / exchange_rate * 100), 2) as avg_spread
|
||||
FROM br_transaction_to_usa
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND DATE(created_at) < CURDATE() AND ptax > 0
|
||||
`);
|
||||
const todaySpread = Number(today[0]?.avg_spread) || 0;
|
||||
const avgSpread = Number(avg30[0]?.avg_spread) || 0;
|
||||
if (avgSpread > 0 && Math.abs(todaySpread - avgSpread) > avgSpread * 0.5) {
|
||||
return {
|
||||
triggered: true,
|
||||
message: `Spread anomaly: ${todaySpread}% today vs ${avgSpread}% 30d avg (${Math.round((todaySpread - avgSpread) / avgSpread * 100)}% deviation)`,
|
||||
data: { todaySpread, avgSpread }
|
||||
};
|
||||
}
|
||||
return { triggered: false };
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'large_transaction',
|
||||
schedule: '*/10 * * * *', // Every 10 minutes
|
||||
severity: 'P0',
|
||||
async check() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
// Check for transactions > $50K in the last 10 minutes
|
||||
const [rows] = await conn.execute(`
|
||||
SELECT t.id, c.nome, t.amount_usd, t.created_at
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
WHERE t.created_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)
|
||||
AND t.amount_usd >= 50000
|
||||
ORDER BY t.amount_usd DESC
|
||||
`);
|
||||
if (rows.length > 0) {
|
||||
const txList = rows.map(r => `${r.nome}: $${Number(r.amount_usd).toLocaleString()}`).join(', ');
|
||||
return {
|
||||
triggered: true,
|
||||
message: `Large transaction(s) detected: ${txList}`,
|
||||
data: { count: rows.length, transactions: rows.map(r => ({ id: r.id, nome: r.nome, usd: Number(r.amount_usd) })) }
|
||||
};
|
||||
}
|
||||
return { triggered: false };
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'failed_tx_spike',
|
||||
schedule: '*/30 * * * *', // Every 30 minutes
|
||||
severity: 'P1',
|
||||
async check() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [stats] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status NOT IN ('boleto_pago','finalizado')
|
||||
AND (date_sent_usa IS NULL OR date_sent_usa = '0000-00-00 00:00:00') THEN 1 ELSE 0 END) as failed
|
||||
FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) = CURDATE()
|
||||
`);
|
||||
const total = Number(stats[0]?.total) || 0;
|
||||
const failed = Number(stats[0]?.failed) || 0;
|
||||
const rate = total > 0 ? (failed / total * 100) : 0;
|
||||
if (total >= 10 && rate > 5) {
|
||||
return {
|
||||
triggered: true,
|
||||
message: `Failed tx spike: ${failed}/${total} (${rate.toFixed(1)}%) today`,
|
||||
data: { total, failed, rate: Math.round(rate * 10) / 10 }
|
||||
};
|
||||
}
|
||||
return { triggered: false };
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'provider_inactivity',
|
||||
schedule: '0 9,14 * * 1-5', // 9am and 2pm weekdays
|
||||
severity: 'P2',
|
||||
async check() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
// Providers with no activity in last 24h that had activity in previous 7 days
|
||||
const [inactive] = await conn.execute(`
|
||||
SELECT pm.provider, MAX(t.created_at) as last_tx,
|
||||
TIMESTAMPDIFF(HOUR, MAX(t.created_at), NOW()) as hours_inactive
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
WHERE t.created_at >= DATE_SUB(CURDATE(), INTERVAL 8 DAY)
|
||||
AND pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')
|
||||
GROUP BY pm.provider
|
||||
HAVING MAX(t.created_at) < DATE_SUB(NOW(), INTERVAL 24 HOUR)
|
||||
`);
|
||||
if (inactive.length > 0) {
|
||||
const list = inactive.map(r => `${r.provider} (${r.hours_inactive}h)`).join(', ');
|
||||
return {
|
||||
triggered: true,
|
||||
message: `Provider inactivity: ${list}`,
|
||||
data: { providers: inactive.map(r => ({ provider: r.provider, hours: Number(r.hours_inactive) })) }
|
||||
};
|
||||
}
|
||||
return { triggered: false };
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Dedup: don't fire same alert within cooldown period
|
||||
const COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
|
||||
const _lastFired = {};
|
||||
|
||||
async function runRule(rule) {
|
||||
try {
|
||||
const result = await rule.check();
|
||||
if (!result.triggered) return;
|
||||
|
||||
// Cooldown check
|
||||
const lastTime = _lastFired[rule.name] || 0;
|
||||
if (Date.now() - lastTime < COOLDOWN_MS) {
|
||||
console.log(`[Alert] ${rule.name} in cooldown, skipping`);
|
||||
return;
|
||||
}
|
||||
_lastFired[rule.name] = Date.now();
|
||||
|
||||
// Store in SQLite
|
||||
createAlert(rule.name, rule.severity, result.message, result.data);
|
||||
|
||||
// Send email notification
|
||||
const emailTo = process.env.ALERT_EMAIL_TO;
|
||||
if (emailTo) {
|
||||
const severityEmoji = { P0: 'CRITICAL', P1: 'WARNING', P2: 'INFO' };
|
||||
await sendEmail(
|
||||
emailTo,
|
||||
`[${rule.severity}] ${severityEmoji[rule.severity] || ''} ${rule.name}`,
|
||||
`
|
||||
<h3 style="color: ${rule.severity === 'P0' ? '#D93025' : rule.severity === 'P1' ? '#E8710A' : '#1A73E8'};">
|
||||
${rule.severity} - ${rule.name}
|
||||
</h3>
|
||||
<p style="font-size: 15px; margin: 12px 0;">${result.message}</p>
|
||||
<p style="font-size: 12px; color: #666;">
|
||||
${new Date().toISOString()} | <a href="${process.env.BASE_URL || 'http://localhost:3080'}/admin/bi">Open BI Dashboard</a>
|
||||
</p>
|
||||
`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Alert] Rule ${rule.name} error:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start alert engine — schedules all cron jobs
|
||||
*/
|
||||
function startAlertEngine() {
|
||||
console.log('[Alert Engine] Starting with', alertRules.length, 'rules');
|
||||
|
||||
alertRules.forEach(rule => {
|
||||
cron.schedule(rule.schedule, () => runRule(rule));
|
||||
console.log(`[Alert Engine] Scheduled: ${rule.name} (${rule.schedule}) [${rule.severity}]`);
|
||||
});
|
||||
|
||||
// Run all rules once on startup (after 30s delay to let DB warm up)
|
||||
setTimeout(() => {
|
||||
console.log('[Alert Engine] Running initial check...');
|
||||
alertRules.forEach(rule => runRule(rule));
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startAlertEngine,
|
||||
createAlert,
|
||||
getAlerts,
|
||||
acknowledgeAlert,
|
||||
getAlertHistory,
|
||||
getUnackedCount
|
||||
};
|
||||
99
src/alerts/channels.js
Normal file
99
src/alerts/channels.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Alert Notification Channels
|
||||
* Email (SMTP via nodemailer), future Slack support
|
||||
*/
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
let _transporter = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (_transporter) return _transporter;
|
||||
|
||||
const host = process.env.SMTP_HOST;
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
console.warn('[Alerts] SMTP not configured (SMTP_HOST, SMTP_USER, SMTP_PASS). Email alerts disabled.');
|
||||
return null;
|
||||
}
|
||||
|
||||
_transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: { user, pass }
|
||||
});
|
||||
|
||||
return _transporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email alert
|
||||
* @param {string} to - Recipient email
|
||||
* @param {string} subject - Email subject
|
||||
* @param {string} html - HTML body
|
||||
*/
|
||||
async function sendEmail(to, subject, html) {
|
||||
const transporter = getTransporter();
|
||||
if (!transporter) {
|
||||
console.log(`[Alerts] Email skipped (SMTP not configured): ${subject}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||
to,
|
||||
subject: `[BI-CCC Alert] ${subject}`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #7600BE; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
|
||||
<h2 style="margin: 0; font-size: 18px;">BI-CCC Alert</h2>
|
||||
</div>
|
||||
<div style="padding: 24px; border: 1px solid #E8EAED; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
${html}
|
||||
</div>
|
||||
<p style="font-size: 11px; color: #9AA0A6; text-align: center; margin-top: 12px;">
|
||||
CambioReal BI - Central Command Center
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
console.log(`[Alerts] Email sent: ${subject} -> ${to}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[Alerts] Email failed: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Slack notification (future — ready when webhook URL is available)
|
||||
*/
|
||||
async function sendSlack(webhook, message, severity) {
|
||||
if (!webhook) return false;
|
||||
|
||||
const colorMap = { P0: '#D93025', P1: '#E8710A', P2: '#1A73E8' };
|
||||
try {
|
||||
const resp = await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
attachments: [{
|
||||
color: colorMap[severity] || '#1A73E8',
|
||||
title: `[${severity}] BI-CCC Alert`,
|
||||
text: message,
|
||||
footer: 'CambioReal BI-CCC',
|
||||
ts: Math.floor(Date.now() / 1000)
|
||||
}]
|
||||
})
|
||||
});
|
||||
return resp.ok;
|
||||
} catch (err) {
|
||||
console.error(`[Alerts] Slack failed: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sendEmail, sendSlack };
|
||||
60
src/db-analytics.js
Normal file
60
src/db-analytics.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Analytics SQLite Database — Local aggregated metrics
|
||||
* Stores daily/monthly aggregates for fast historical queries
|
||||
*/
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const DB_PATH = path.join(__dirname, '..', 'data', 'analytics.db');
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// Daily metrics table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS daily_metrics (
|
||||
date TEXT NOT NULL,
|
||||
product TEXT NOT NULL,
|
||||
transactions INTEGER DEFAULT 0,
|
||||
volume_usd REAL DEFAULT 0,
|
||||
volume_brl REAL DEFAULT 0,
|
||||
revenue REAL DEFAULT 0,
|
||||
avg_spread REAL DEFAULT 0,
|
||||
avg_ticket REAL DEFAULT 0,
|
||||
unique_clients INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (date, product)
|
||||
)
|
||||
`);
|
||||
|
||||
// Client health daily snapshot
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS client_health_daily (
|
||||
date TEXT NOT NULL,
|
||||
id_conta INTEGER NOT NULL,
|
||||
health_score INTEGER DEFAULT 0,
|
||||
churn_risk TEXT DEFAULT 'medium',
|
||||
volume_usd REAL DEFAULT 0,
|
||||
tx_count INTEGER DEFAULT 0,
|
||||
last_tx_date TEXT,
|
||||
PRIMARY KEY (date, id_conta)
|
||||
)
|
||||
`);
|
||||
|
||||
// Monthly revenue per client
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS monthly_revenue (
|
||||
month TEXT NOT NULL,
|
||||
id_conta INTEGER NOT NULL,
|
||||
revenue REAL DEFAULT 0,
|
||||
volume_usd REAL DEFAULT 0,
|
||||
tx_count INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (month, id_conta)
|
||||
)
|
||||
`);
|
||||
|
||||
// Indexes for fast lookups
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_daily_date ON daily_metrics(date)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_health_date ON client_health_daily(date)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_monthly_month ON monthly_revenue(month)`);
|
||||
|
||||
module.exports = db;
|
||||
@@ -56,4 +56,17 @@ for (const admin of admins) {
|
||||
}
|
||||
}
|
||||
|
||||
// Alerts table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
data TEXT,
|
||||
acknowledged INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
module.exports = db;
|
||||
|
||||
187
src/etl/daily-sync.js
Normal file
187
src/etl/daily-sync.js
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* ETL Daily Sync — MySQL RDS → SQLite Analytics
|
||||
* Runs daily at 1 AM via node-cron, computes aggregates for previous day
|
||||
*/
|
||||
const cron = require('node-cron');
|
||||
const pool = require('../db-rds');
|
||||
const analyticsDb = require('../db-analytics');
|
||||
const { runDataQuality } = require('./data-quality');
|
||||
|
||||
/**
|
||||
* Sync a single day's metrics from MySQL to SQLite
|
||||
*/
|
||||
async function syncDay(dateStr) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
console.log(`[ETL] Syncing day: ${dateStr}`);
|
||||
|
||||
// BRL→USD metrics
|
||||
const [brlUsd] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as transactions,
|
||||
ROUND(COALESCE(SUM(amount_usd), 0), 2) as volume_usd,
|
||||
ROUND(COALESCE(SUM(amount_brl), 0), 2) as volume_brl,
|
||||
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd), 0), 2) as revenue,
|
||||
ROUND(COALESCE(AVG((exchange_rate - ptax) / exchange_rate * 100), 0), 4) as avg_spread,
|
||||
ROUND(COALESCE(AVG(amount_usd), 0), 2) as avg_ticket,
|
||||
COUNT(DISTINCT id_conta) as unique_clients
|
||||
FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) = ?
|
||||
`, [dateStr]);
|
||||
|
||||
// USD→BRL metrics
|
||||
const [usdBrl] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as transactions,
|
||||
ROUND(COALESCE(SUM(valor), 0), 2) as volume_usd,
|
||||
ROUND(COALESCE(SUM(valor_sol), 0), 2) as volume_brl,
|
||||
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor), 0), 2) as revenue,
|
||||
ROUND(COALESCE(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END), 0), 4) as avg_spread,
|
||||
ROUND(COALESCE(AVG(valor), 0), 2) as avg_ticket,
|
||||
COUNT(DISTINCT id_conta) as unique_clients
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) = ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0
|
||||
AND (pgto IS NULL OR pgto != 'balance')
|
||||
`, [dateStr]);
|
||||
|
||||
// USD→USD metrics
|
||||
const [usdUsd] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as transactions,
|
||||
ROUND(COALESCE(SUM(valor), 0), 2) as volume_usd,
|
||||
0 as volume_brl,
|
||||
0 as revenue,
|
||||
0 as avg_spread,
|
||||
ROUND(COALESCE(AVG(valor), 0), 2) as avg_ticket,
|
||||
COUNT(DISTINCT id_conta) as unique_clients
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) = ?
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
`, [dateStr]);
|
||||
|
||||
// Upsert into SQLite
|
||||
const upsert = analyticsDb.prepare(`
|
||||
INSERT INTO daily_metrics (date, product, transactions, volume_usd, volume_brl, revenue, avg_spread, avg_ticket, unique_clients)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(date, product) DO UPDATE SET
|
||||
transactions=excluded.transactions, volume_usd=excluded.volume_usd, volume_brl=excluded.volume_brl,
|
||||
revenue=excluded.revenue, avg_spread=excluded.avg_spread, avg_ticket=excluded.avg_ticket,
|
||||
unique_clients=excluded.unique_clients
|
||||
`);
|
||||
|
||||
const insertMetrics = analyticsDb.transaction((rows) => {
|
||||
rows.forEach(r => upsert.run(r.date, r.product, r.transactions, r.volume_usd, r.volume_brl, r.revenue, r.avg_spread, r.avg_ticket, r.unique_clients));
|
||||
});
|
||||
|
||||
const b = brlUsd[0] || {};
|
||||
const u = usdBrl[0] || {};
|
||||
const uu = usdUsd[0] || {};
|
||||
|
||||
insertMetrics([
|
||||
{ date: dateStr, product: 'BRL→USD', transactions: Number(b.transactions) || 0, volume_usd: Number(b.volume_usd) || 0, volume_brl: Number(b.volume_brl) || 0, revenue: Number(b.revenue) || 0, avg_spread: Number(b.avg_spread) || 0, avg_ticket: Number(b.avg_ticket) || 0, unique_clients: Number(b.unique_clients) || 0 },
|
||||
{ date: dateStr, product: 'USD→BRL', transactions: Number(u.transactions) || 0, volume_usd: Number(u.volume_usd) || 0, volume_brl: Number(u.volume_brl) || 0, revenue: Number(u.revenue) || 0, avg_spread: Number(u.avg_spread) || 0, avg_ticket: Number(u.avg_ticket) || 0, unique_clients: Number(u.unique_clients) || 0 },
|
||||
{ date: dateStr, product: 'USD→USD', transactions: Number(uu.transactions) || 0, volume_usd: Number(uu.volume_usd) || 0, volume_brl: 0, revenue: 0, avg_spread: 0, avg_ticket: Number(uu.avg_ticket) || 0, unique_clients: Number(uu.unique_clients) || 0 }
|
||||
]);
|
||||
|
||||
// Monthly revenue aggregation
|
||||
const month = dateStr.slice(0, 7);
|
||||
const [monthlyRevBrl] = await conn.execute(`
|
||||
SELECT id_conta,
|
||||
ROUND(SUM((exchange_rate - ptax) / exchange_rate * amount_usd), 2) as revenue,
|
||||
ROUND(SUM(amount_usd), 2) as volume_usd,
|
||||
COUNT(*) as tx_count
|
||||
FROM br_transaction_to_usa
|
||||
WHERE DATE_FORMAT(created_at, '%Y-%m') = ? AND DATE(created_at) <= ?
|
||||
GROUP BY id_conta
|
||||
`, [month, dateStr]);
|
||||
|
||||
const [monthlyRevUsd] = await conn.execute(`
|
||||
SELECT id_conta,
|
||||
ROUND(SUM((ptax - cotacao) / ptax * valor), 2) as revenue,
|
||||
ROUND(SUM(valor), 2) as volume_usd,
|
||||
COUNT(*) as tx_count
|
||||
FROM pagamento_br
|
||||
WHERE DATE_FORMAT(created_at, '%Y-%m') = ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0
|
||||
AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY id_conta
|
||||
`, [month, dateStr]);
|
||||
|
||||
const monthlyUpsert = analyticsDb.prepare(`
|
||||
INSERT INTO monthly_revenue (month, id_conta, revenue, volume_usd, tx_count)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(month, id_conta) DO UPDATE SET
|
||||
revenue=excluded.revenue, volume_usd=excluded.volume_usd, tx_count=excluded.tx_count
|
||||
`);
|
||||
|
||||
const clientMap = {};
|
||||
[...monthlyRevBrl, ...monthlyRevUsd].forEach(r => {
|
||||
const id = r.id_conta;
|
||||
if (!clientMap[id]) clientMap[id] = { revenue: 0, volume_usd: 0, tx_count: 0 };
|
||||
clientMap[id].revenue += Number(r.revenue) || 0;
|
||||
clientMap[id].volume_usd += Number(r.volume_usd) || 0;
|
||||
clientMap[id].tx_count += Number(r.tx_count) || 0;
|
||||
});
|
||||
|
||||
const insertMonthly = analyticsDb.transaction((map) => {
|
||||
Object.entries(map).forEach(([id, d]) => {
|
||||
monthlyUpsert.run(month, parseInt(id), Math.round(d.revenue * 100) / 100, Math.round(d.volume_usd * 100) / 100, d.tx_count);
|
||||
});
|
||||
});
|
||||
insertMonthly(clientMap);
|
||||
|
||||
console.log(`[ETL] Day ${dateStr} synced: BRL→USD=${b.transactions || 0}tx, USD→BRL=${u.transactions || 0}tx, USD→USD=${uu.transactions || 0}tx, ${Object.keys(clientMap).length} client revenue records`);
|
||||
return true;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill: sync multiple days
|
||||
*/
|
||||
async function backfill(daysBack = 90) {
|
||||
console.log(`[ETL] Backfilling ${daysBack} days...`);
|
||||
const now = new Date();
|
||||
for (let i = daysBack; i >= 1; i--) {
|
||||
const d = new Date(now.getTime() - i * 86400000);
|
||||
const dateStr = d.toISOString().slice(0, 10);
|
||||
try {
|
||||
await syncDay(dateStr);
|
||||
} catch (err) {
|
||||
console.error(`[ETL] Backfill error for ${dateStr}:`, err.message);
|
||||
}
|
||||
}
|
||||
console.log(`[ETL] Backfill complete`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start daily ETL sync job
|
||||
*/
|
||||
function startETL() {
|
||||
// Run at 1:00 AM every day
|
||||
cron.schedule('0 1 * * *', async () => {
|
||||
try {
|
||||
// Sync yesterday
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
await syncDay(yesterday);
|
||||
// Run data quality checks
|
||||
runDataQuality(yesterday);
|
||||
} catch (err) {
|
||||
console.error('[ETL] Daily sync error:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[ETL] Daily sync scheduled at 1:00 AM');
|
||||
|
||||
// Check if analytics DB is empty; if so, backfill
|
||||
const count = analyticsDb.prepare('SELECT COUNT(*) as c FROM daily_metrics').get();
|
||||
if (!count || count.c === 0) {
|
||||
console.log('[ETL] Analytics DB empty, starting backfill...');
|
||||
// Run backfill in background
|
||||
backfill(90).catch(err => console.error('[ETL] Backfill error:', err.message));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { startETL, syncDay, backfill };
|
||||
114
src/etl/data-quality.js
Normal file
114
src/etl/data-quality.js
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* ETL Data Quality — Post-sync validation checks
|
||||
* Runs after each daily sync to verify data integrity
|
||||
*/
|
||||
const pool = require('../db-rds');
|
||||
const analyticsDb = require('../db-analytics');
|
||||
|
||||
/**
|
||||
* Run data quality checks for a given date
|
||||
*/
|
||||
async function runDataQuality(dateStr) {
|
||||
const issues = [];
|
||||
console.log(`[DQ] Running data quality checks for ${dateStr}...`);
|
||||
|
||||
// 1. Row count validation — SQLite should have 3 product rows per synced day
|
||||
const sqliteRows = analyticsDb.prepare(
|
||||
'SELECT COUNT(*) as c FROM daily_metrics WHERE date = ?'
|
||||
).get(dateStr);
|
||||
|
||||
if (!sqliteRows || sqliteRows.c !== 3) {
|
||||
issues.push(`Row count: expected 3 product rows, got ${sqliteRows?.c || 0}`);
|
||||
}
|
||||
|
||||
// 2. Null / negative checks on SQLite aggregates
|
||||
const metrics = analyticsDb.prepare(
|
||||
'SELECT product, transactions, volume_usd, revenue, avg_spread FROM daily_metrics WHERE date = ?'
|
||||
).all(dateStr);
|
||||
|
||||
for (const m of metrics) {
|
||||
if (m.transactions < 0) issues.push(`${m.product}: negative transaction count (${m.transactions})`);
|
||||
if (m.volume_usd < 0) issues.push(`${m.product}: negative volume_usd (${m.volume_usd})`);
|
||||
if (m.revenue < 0 && m.product !== 'USD→USD') issues.push(`${m.product}: negative revenue (${m.revenue})`);
|
||||
if (m.avg_spread < 0 && m.product !== 'USD→USD') issues.push(`${m.product}: negative avg_spread (${m.avg_spread})`);
|
||||
}
|
||||
|
||||
// 3. Revenue reconciliation — compare SQLite aggregates vs MySQL source
|
||||
let conn;
|
||||
try {
|
||||
conn = await pool.getConnection();
|
||||
|
||||
// BRL→USD reconciliation
|
||||
const [mysqlBrlUsd] = await conn.execute(`
|
||||
SELECT COUNT(*) as tx_count,
|
||||
ROUND(COALESCE(SUM(amount_usd), 0), 2) as volume_usd
|
||||
FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) = ?
|
||||
`, [dateStr]);
|
||||
|
||||
const sqliteBrlUsd = analyticsDb.prepare(
|
||||
"SELECT transactions, volume_usd FROM daily_metrics WHERE date = ? AND product = 'BRL→USD'"
|
||||
).get(dateStr);
|
||||
|
||||
if (mysqlBrlUsd[0] && sqliteBrlUsd) {
|
||||
const txDelta = Math.abs(Number(mysqlBrlUsd[0].tx_count) - sqliteBrlUsd.transactions);
|
||||
if (txDelta > 0) {
|
||||
issues.push(`BRL→USD tx count mismatch: MySQL=${mysqlBrlUsd[0].tx_count}, SQLite=${sqliteBrlUsd.transactions}`);
|
||||
}
|
||||
const volDelta = Math.abs(Number(mysqlBrlUsd[0].volume_usd) - sqliteBrlUsd.volume_usd);
|
||||
if (volDelta > 0.01) {
|
||||
issues.push(`BRL→USD volume mismatch: MySQL=${mysqlBrlUsd[0].volume_usd}, SQLite=${sqliteBrlUsd.volume_usd}, delta=${volDelta.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// USD→BRL reconciliation
|
||||
const [mysqlUsdBrl] = await conn.execute(`
|
||||
SELECT COUNT(*) as tx_count,
|
||||
ROUND(COALESCE(SUM(valor), 0), 2) as volume_usd
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) = ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0
|
||||
AND (pgto IS NULL OR pgto != 'balance')
|
||||
`, [dateStr]);
|
||||
|
||||
const sqliteUsdBrl = analyticsDb.prepare(
|
||||
"SELECT transactions, volume_usd FROM daily_metrics WHERE date = ? AND product = 'USD→BRL'"
|
||||
).get(dateStr);
|
||||
|
||||
if (mysqlUsdBrl[0] && sqliteUsdBrl) {
|
||||
const txDelta = Math.abs(Number(mysqlUsdBrl[0].tx_count) - sqliteUsdBrl.transactions);
|
||||
if (txDelta > 0) {
|
||||
issues.push(`USD→BRL tx count mismatch: MySQL=${mysqlUsdBrl[0].tx_count}, SQLite=${sqliteUsdBrl.transactions}`);
|
||||
}
|
||||
const volDelta = Math.abs(Number(mysqlUsdBrl[0].volume_usd) - sqliteUsdBrl.volume_usd);
|
||||
if (volDelta > 0.01) {
|
||||
issues.push(`USD→BRL volume mismatch: MySQL=${mysqlUsdBrl[0].volume_usd}, SQLite=${sqliteUsdBrl.volume_usd}, delta=${volDelta.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Monthly revenue check — sum of client revenues should be positive if there are transactions
|
||||
const month = dateStr.slice(0, 7);
|
||||
const monthlySum = analyticsDb.prepare(
|
||||
'SELECT SUM(revenue) as total_rev, COUNT(*) as clients FROM monthly_revenue WHERE month = ?'
|
||||
).get(month);
|
||||
|
||||
const totalDayTx = metrics.reduce((s, m) => s + m.transactions, 0);
|
||||
if (totalDayTx > 0 && monthlySum && monthlySum.total_rev <= 0 && monthlySum.clients > 0) {
|
||||
issues.push(`Monthly revenue for ${month} is ${monthlySum.total_rev} despite ${totalDayTx} transactions on ${dateStr}`);
|
||||
}
|
||||
} finally {
|
||||
if (conn) conn.release();
|
||||
}
|
||||
|
||||
// Report results
|
||||
if (issues.length === 0) {
|
||||
console.log(`[DQ] ${dateStr}: All checks passed`);
|
||||
} else {
|
||||
console.warn(`[DQ] ${dateStr}: ${issues.length} issue(s) found:`);
|
||||
issues.forEach(i => console.warn(`[DQ] - ${i}`));
|
||||
}
|
||||
|
||||
return { date: dateStr, passed: issues.length === 0, issues };
|
||||
}
|
||||
|
||||
module.exports = { runDataQuality };
|
||||
120
src/export/excel-export.js
Normal file
120
src/export/excel-export.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Excel Export Engine — CambioReal branded spreadsheets
|
||||
* Uses ExcelJS for .xlsx generation with styled headers
|
||||
*/
|
||||
const ExcelJS = require('exceljs');
|
||||
|
||||
// CambioReal brand colors
|
||||
const HEADER_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF7600BE' } };
|
||||
const HEADER_FONT = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 };
|
||||
const CURRENCY_FORMAT = '#,##0.00';
|
||||
const PCT_FORMAT = '0.00%';
|
||||
const DATE_FORMAT = 'yyyy-mm-dd';
|
||||
|
||||
/**
|
||||
* Generate an Excel workbook from data
|
||||
* @param {Object[]} data - Array of row objects
|
||||
* @param {Object[]} columns - Column definitions: { header, key, width?, type? }
|
||||
* type: 'currency' | 'percentage' | 'date' | 'number' | 'text' (default)
|
||||
* @param {string} sheetName - Worksheet name
|
||||
* @returns {ExcelJS.Workbook}
|
||||
*/
|
||||
function createWorkbook(data, columns, sheetName = 'Data') {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'CambioReal BI-CCC';
|
||||
workbook.created = new Date();
|
||||
|
||||
const sheet = workbook.addWorksheet(sheetName);
|
||||
|
||||
// Define columns
|
||||
sheet.columns = columns.map(col => ({
|
||||
header: col.header,
|
||||
key: col.key,
|
||||
width: col.width || 15
|
||||
}));
|
||||
|
||||
// Style header row
|
||||
const headerRow = sheet.getRow(1);
|
||||
headerRow.eachCell((cell) => {
|
||||
cell.fill = HEADER_FILL;
|
||||
cell.font = HEADER_FONT;
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
cell.border = {
|
||||
bottom: { style: 'thin', color: { argb: 'FF5A0091' } }
|
||||
};
|
||||
});
|
||||
headerRow.height = 28;
|
||||
|
||||
// Add data rows
|
||||
data.forEach(row => {
|
||||
const addedRow = sheet.addRow(row);
|
||||
|
||||
// Apply formatting per column type
|
||||
columns.forEach((col, idx) => {
|
||||
const cell = addedRow.getCell(idx + 1);
|
||||
switch (col.type) {
|
||||
case 'currency':
|
||||
cell.numFmt = CURRENCY_FORMAT;
|
||||
cell.alignment = { horizontal: 'right' };
|
||||
break;
|
||||
case 'percentage':
|
||||
// If value is already 0-100 range, divide by 100 for Excel percentage format
|
||||
if (typeof cell.value === 'number' && cell.value > 1) {
|
||||
cell.value = cell.value / 100;
|
||||
}
|
||||
cell.numFmt = PCT_FORMAT;
|
||||
cell.alignment = { horizontal: 'right' };
|
||||
break;
|
||||
case 'date':
|
||||
cell.numFmt = DATE_FORMAT;
|
||||
break;
|
||||
case 'number':
|
||||
cell.numFmt = '#,##0';
|
||||
cell.alignment = { horizontal: 'right' };
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-filter
|
||||
if (data.length > 0) {
|
||||
sheet.autoFilter = {
|
||||
from: { row: 1, column: 1 },
|
||||
to: { row: data.length + 1, column: columns.length }
|
||||
};
|
||||
}
|
||||
|
||||
// Freeze header row
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
|
||||
return workbook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export workbook to Express response as .xlsx download
|
||||
* @param {import('express').Response} res - Express response
|
||||
* @param {ExcelJS.Workbook} workbook
|
||||
* @param {string} filename - Without extension
|
||||
*/
|
||||
async function sendWorkbook(res, workbook, filename) {
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}.xlsx"`);
|
||||
await workbook.xlsx.write(res);
|
||||
res.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick helper: data + columns → Express download
|
||||
*/
|
||||
async function exportToExcel(res, data, columns, sheetName, filename) {
|
||||
const workbook = createWorkbook(data, columns, sheetName);
|
||||
await sendWorkbook(res, workbook, filename);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createWorkbook,
|
||||
sendWorkbook,
|
||||
exportToExcel
|
||||
};
|
||||
1810
src/queries.js
1810
src/queries.js
File diff suppressed because it is too large
Load Diff
659
src/queries/bi.queries.js
Normal file
659
src/queries/bi.queries.js
Normal file
@@ -0,0 +1,659 @@
|
||||
/**
|
||||
* BI Executive Dashboard Queries
|
||||
* Comprehensive analytics: KPIs, revenue P&L, strategic cohort analysis
|
||||
*/
|
||||
const { pool, fmtDate, fmtTrendRows, calcPrevPeriod } = require('./helpers');
|
||||
|
||||
// BI Analytics - Comprehensive data for admin BI dashboard
|
||||
async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const { prevStartStr, prevEndStr } = calcPrevPeriod(dataInicio, dataFim);
|
||||
|
||||
// 1. BRL→USD KPIs
|
||||
const [kpiBrlUsd] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as qtd,
|
||||
ROUND(COALESCE(SUM(amount_usd), 0), 2) as vol_usd,
|
||||
ROUND(COALESCE(SUM(amount_brl), 0), 2) as vol_brl,
|
||||
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd), 0), 2) as spread_revenue,
|
||||
ROUND(COALESCE(AVG((exchange_rate - ptax) / exchange_rate * 100), 0), 2) as avg_spread_pct,
|
||||
COUNT(DISTINCT id_conta) as clientes
|
||||
FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 2. USD→BRL KPIs
|
||||
const [kpiUsdBrl] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as qtd,
|
||||
ROUND(COALESCE(SUM(valor), 0), 2) as vol_usd,
|
||||
ROUND(COALESCE(SUM(valor_sol), 0), 2) as vol_brl,
|
||||
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor), 0), 2) as spread_revenue,
|
||||
ROUND(COALESCE(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END), 0), 2) as avg_spread_pct,
|
||||
COUNT(DISTINCT id_conta) as clientes
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0
|
||||
AND (pgto IS NULL OR pgto != 'balance')
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 3. USD→USD KPIs
|
||||
const [kpiUsdUsd] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as qtd,
|
||||
ROUND(COALESCE(SUM(valor), 0), 2) as vol_usd,
|
||||
COUNT(DISTINCT id_conta) as clientes
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 4. Unique active clients across all flows
|
||||
const [uniqueClients] = await conn.execute(`
|
||||
SELECT COUNT(DISTINCT id_conta) as total FROM (
|
||||
SELECT id_conta FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
UNION
|
||||
SELECT id_conta FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
) all_clients
|
||||
`, [dataInicio, dataFim, dataInicio, dataFim]);
|
||||
|
||||
// 5. Previous period totals for comparison
|
||||
const [prevBrlUsd] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
||||
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),0),2) as spread_revenue
|
||||
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
`, [prevStartStr, prevEndStr]);
|
||||
const [prevUsdBrl] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
||||
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor),0),2) as spread_revenue
|
||||
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
`, [prevStartStr, prevEndStr]);
|
||||
const [prevUsdUsd] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd
|
||||
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
`, [prevStartStr, prevEndStr]);
|
||||
|
||||
// 6. BRL→USD daily trend with spread
|
||||
const [trendBrlUsd] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(amount_usd), 2) as vol_usd,
|
||||
ROUND(AVG((exchange_rate - ptax) / exchange_rate * 100), 2) as avg_spread
|
||||
FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 7. USD→BRL daily trend with spread
|
||||
const [trendUsdBrl] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(valor), 2) as vol_usd,
|
||||
ROUND(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END), 2) as avg_spread
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0
|
||||
AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 8. USD→USD daily trend
|
||||
const [trendUsdUsd] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(valor), 2) as vol_usd
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// 9. Top 10 clients by volume
|
||||
const [topClients] = await conn.execute(`
|
||||
SELECT nome, SUM(vol) as total_usd, SUM(qtd) as total_qtd FROM (
|
||||
SELECT c.nome, SUM(t.amount_usd) as vol, COUNT(*) as qtd
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
GROUP BY c.nome
|
||||
UNION ALL
|
||||
SELECT c.nome, SUM(p.valor) as vol, COUNT(*) as qtd
|
||||
FROM pagamento_br p
|
||||
INNER JOIN conta c ON c.id_conta = p.id_conta
|
||||
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
||||
GROUP BY c.nome
|
||||
) combined
|
||||
GROUP BY nome ORDER BY total_usd DESC LIMIT 10
|
||||
`, [dataInicio, dataFim, dataInicio, dataFim]);
|
||||
|
||||
// 10. Client retention
|
||||
const [retention] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(DISTINCT prev.id_conta) as prev_clients,
|
||||
COUNT(DISTINCT CASE WHEN curr.id_conta IS NOT NULL THEN prev.id_conta END) as retained
|
||||
FROM (
|
||||
SELECT DISTINCT id_conta FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
UNION
|
||||
SELECT DISTINCT id_conta FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
) prev
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT id_conta FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
UNION
|
||||
SELECT DISTINCT id_conta FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
) curr ON prev.id_conta = curr.id_conta
|
||||
`, [prevStartStr, prevEndStr, prevStartStr, prevEndStr, dataInicio, dataFim, dataInicio, dataFim]);
|
||||
|
||||
// 11. Clients at risk
|
||||
const [clientsAtRisk] = await conn.execute(`
|
||||
SELECT nome, MAX(last_op) as last_op, SUM(vol) as total_usd, SUM(qtd) as total_qtd,
|
||||
DATEDIFF(CURDATE(), MAX(last_op)) as days_inactive
|
||||
FROM (
|
||||
SELECT c.nome, MAX(t.created_at) as last_op, SUM(t.amount_usd) as vol, COUNT(*) as qtd
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
GROUP BY c.nome
|
||||
UNION ALL
|
||||
SELECT c.nome, MAX(p.created_at) as last_op, SUM(p.valor) as vol, COUNT(*) as qtd
|
||||
FROM pagamento_br p
|
||||
INNER JOIN conta c ON c.id_conta = p.id_conta
|
||||
GROUP BY c.nome
|
||||
) combined
|
||||
GROUP BY nome
|
||||
HAVING MAX(last_op) < CURDATE()
|
||||
ORDER BY total_usd DESC LIMIT 20
|
||||
`);
|
||||
|
||||
// 12. Agent ranking with spread revenue
|
||||
const [agentRanking] = await conn.execute(`
|
||||
SELECT agente_id, SUM(vol) as total_usd, SUM(qtd) as total_qtd,
|
||||
ROUND(SUM(spread_rev), 2) as total_spread, COUNT(DISTINCT client_id) as clientes
|
||||
FROM (
|
||||
SELECT ac.agente_id, t.id_conta as client_id, SUM(t.amount_usd) as vol, COUNT(*) as qtd,
|
||||
SUM((t.exchange_rate - t.ptax) / t.exchange_rate * t.amount_usd) as spread_rev
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN ag_contas ac ON ac.conta_id = t.id_conta
|
||||
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
GROUP BY ac.agente_id, t.id_conta
|
||||
UNION ALL
|
||||
SELECT ac.agente_id, p.id_conta as client_id, SUM(p.valor) as vol, COUNT(*) as qtd,
|
||||
SUM((p.ptax - p.cotacao) / p.ptax * p.valor) as spread_rev
|
||||
FROM pagamento_br p
|
||||
INNER JOIN ag_contas ac ON ac.conta_id = p.id_conta
|
||||
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
||||
AND p.cotacao IS NOT NULL AND p.cotacao > 0
|
||||
AND (p.pgto IS NULL OR p.pgto != 'balance')
|
||||
GROUP BY ac.agente_id, p.id_conta
|
||||
) combined
|
||||
GROUP BY agente_id ORDER BY total_usd DESC LIMIT 10
|
||||
`, [dataInicio, dataFim, dataInicio, dataFim]);
|
||||
|
||||
// Resolve agent names
|
||||
const agents = agentRanking.map((r, i) => {
|
||||
const nome = getAgenteName ? (getAgenteName(r.agente_id) || `Agente ${r.agente_id}`) : `Agente ${r.agente_id}`;
|
||||
return { rank: i + 1, agente_id: r.agente_id, nome,
|
||||
vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd),
|
||||
spread_revenue: Number(r.total_spread), clientes: Number(r.clientes)
|
||||
};
|
||||
});
|
||||
|
||||
// Format results
|
||||
const fmtKpi = (r) => ({
|
||||
qtd: Number(r?.qtd) || 0, vol_usd: Number(r?.vol_usd) || 0,
|
||||
vol_brl: Number(r?.vol_brl) || 0, spread_revenue: Number(r?.spread_revenue) || 0,
|
||||
avg_spread_pct: Number(r?.avg_spread_pct) || 0, clientes: Number(r?.clientes) || 0
|
||||
});
|
||||
|
||||
const brl = fmtKpi(kpiBrlUsd[0]);
|
||||
const usd = fmtKpi(kpiUsdBrl[0]);
|
||||
const uu = { qtd: Number(kpiUsdUsd[0]?.qtd) || 0, vol_usd: Number(kpiUsdUsd[0]?.vol_usd) || 0, clientes: Number(kpiUsdUsd[0]?.clientes) || 0 };
|
||||
const totalQtd = brl.qtd + usd.qtd + uu.qtd;
|
||||
const totalVolUsd = brl.vol_usd + usd.vol_usd + uu.vol_usd;
|
||||
|
||||
const pBrl = Number(prevBrlUsd[0]?.qtd) || 0;
|
||||
const pUsd = Number(prevUsdBrl[0]?.qtd) || 0;
|
||||
const pUu = Number(prevUsdUsd[0]?.qtd) || 0;
|
||||
const prevQtd = pBrl + pUsd + pUu;
|
||||
const prevVolUsd = (Number(prevBrlUsd[0]?.vol_usd) || 0) + (Number(prevUsdBrl[0]?.vol_usd) || 0) + (Number(prevUsdUsd[0]?.vol_usd) || 0);
|
||||
const prevSpread = (Number(prevBrlUsd[0]?.spread_revenue) || 0) + (Number(prevUsdBrl[0]?.spread_revenue) || 0);
|
||||
|
||||
const retPrev = Number(retention[0]?.prev_clients) || 0;
|
||||
const retCurr = Number(retention[0]?.retained) || 0;
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
brlUsd: brl, usdBrl: usd, usdUsd: uu,
|
||||
total: {
|
||||
qtd: totalQtd, vol_usd: totalVolUsd,
|
||||
spread_revenue: brl.spread_revenue + usd.spread_revenue,
|
||||
clientes: Number(uniqueClients[0]?.total) || 0,
|
||||
ticket_medio: totalQtd > 0 ? Math.round(totalVolUsd / totalQtd) : 0
|
||||
}
|
||||
},
|
||||
comparison: { prev_qtd: prevQtd, prev_vol_usd: prevVolUsd, prev_spread: prevSpread },
|
||||
trend: { brlUsd: fmtTrendRows(trendBrlUsd), usdBrl: fmtTrendRows(trendUsdBrl), usdUsd: fmtTrendRows(trendUsdUsd) },
|
||||
topClients: topClients.map(r => ({ nome: r.nome, vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd) })),
|
||||
retention: { prev_clients: retPrev, retained: retCurr, rate: retPrev > 0 ? Math.round(retCurr / retPrev * 100) : 0 },
|
||||
clientsAtRisk: clientsAtRisk.map(r => ({
|
||||
nome: r.nome, vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd),
|
||||
last_op: r.last_op instanceof Date ? r.last_op.toISOString().slice(0, 10) : String(r.last_op).slice(0, 16),
|
||||
days_inactive: Number(r.days_inactive) || 0
|
||||
})),
|
||||
agentRanking: agents,
|
||||
netting: {
|
||||
saida_usd: brl.vol_usd, entrada_usd: usd.vol_usd,
|
||||
posicao_liquida: usd.vol_usd - brl.vol_usd,
|
||||
eficiencia: brl.vol_usd > 0 ? Math.min(100, Math.round(usd.vol_usd / brl.vol_usd * 100)) : 0
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Revenue Analytics - Real P&L by product with dynamic granularity
|
||||
async function fetchRevenueAnalytics(dataInicio, dataFim, granularity = 'dia') {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const validGran = ['dia', 'mes', 'ano'].includes(granularity) ? granularity : 'dia';
|
||||
|
||||
let periodoInicio, periodoLabel;
|
||||
switch (validGran) {
|
||||
case 'ano':
|
||||
periodoInicio = "MAKEDATE(YEAR(dia), 1)";
|
||||
periodoLabel = "DATE_FORMAT(dia, '%Y')";
|
||||
break;
|
||||
case 'mes':
|
||||
periodoInicio = "CAST(DATE_FORMAT(dia, '%Y-%m-01') AS DATE)";
|
||||
periodoLabel = "DATE_FORMAT(dia, '%Y-%m')";
|
||||
break;
|
||||
default:
|
||||
periodoInicio = "DATE(dia)";
|
||||
periodoLabel = "DATE_FORMAT(dia, '%Y-%m-%d')";
|
||||
}
|
||||
|
||||
const [rows] = await conn.execute(`
|
||||
WITH limites AS (
|
||||
SELECT
|
||||
CAST(? AS DATE) AS inicio,
|
||||
DATE_ADD(CAST(? AS DATE), INTERVAL 1 DAY) AS fim_exclusivo
|
||||
),
|
||||
q1 AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN pb.tipo_envio = 'balance' THEN DATE(pb.data_cp)
|
||||
ELSE DATE(pb.created_at)
|
||||
END AS dia,
|
||||
CONCAT('US→BR: ', COALESCE(pb.tipo_envio, 'desconhecido')) AS produto,
|
||||
CASE
|
||||
WHEN pb.tipo_envio = 'balance' THEN COALESCE(pb.fee, 0)
|
||||
ELSE COALESCE(
|
||||
CASE
|
||||
WHEN pb.ptax IS NOT NULL AND pb.ptax > 0
|
||||
THEN ((pb.ptax - pb.cotacao) * pb.valor) / pb.ptax
|
||||
ELSE 0
|
||||
END, 0
|
||||
) + COALESCE(pb.fee, 0)
|
||||
END AS receita
|
||||
FROM pagamento_br pb
|
||||
JOIN limites l ON (
|
||||
CASE WHEN pb.tipo_envio = 'balance' THEN pb.data_cp ELSE pb.created_at END
|
||||
) >= l.inicio
|
||||
AND (
|
||||
CASE WHEN pb.tipo_envio = 'balance' THEN pb.data_cp ELSE pb.created_at END
|
||||
) < l.fim_exclusivo
|
||||
WHERE pb.valor > 0
|
||||
AND pb.data_cp IS NOT NULL
|
||||
AND pb.data_cp <> '0000-00-00'
|
||||
),
|
||||
q2 AS (
|
||||
SELECT
|
||||
DATE(t.created_at) AS dia,
|
||||
CASE
|
||||
WHEN t.cobranca_id IS NOT NULL THEN 'BR→US: Checkout'
|
||||
ELSE 'BR→US: CambioTransfer'
|
||||
END AS produto,
|
||||
(
|
||||
(
|
||||
ROUND((t.amount_brl - IF(pm.provider IN ('ouribank','bs2'), 0, t.fee)) / t.ptax, 2)
|
||||
- COALESCE(t.pfee, 0)
|
||||
) - (
|
||||
t.amount_usd + COALESCE(t.bonus_valor, 0) - COALESCE(t.taxa_cr, 0)
|
||||
)
|
||||
) AS receita
|
||||
FROM br_transaction_to_usa t
|
||||
JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
JOIN limites l ON t.created_at >= l.inicio AND t.created_at < l.fim_exclusivo
|
||||
WHERE pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')
|
||||
AND t.ptax IS NOT NULL AND t.ptax > 0
|
||||
AND (
|
||||
t.status IN ('boleto_pago','finalizado')
|
||||
OR t.date_sent_usa <> '0000-00-00 00:00:00'
|
||||
)
|
||||
),
|
||||
unioned AS (
|
||||
SELECT dia, produto, receita FROM q1
|
||||
UNION ALL
|
||||
SELECT dia, produto, receita FROM q2
|
||||
)
|
||||
SELECT
|
||||
${periodoInicio} AS periodo_inicio,
|
||||
${periodoLabel} AS periodo_label,
|
||||
produto,
|
||||
ROUND(SUM(receita), 2) AS receita,
|
||||
COUNT(*) AS qtd
|
||||
FROM unioned
|
||||
GROUP BY periodo_inicio, periodo_label, produto
|
||||
ORDER BY periodo_inicio, produto
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
// Also get totals by product
|
||||
const [totals] = await conn.execute(`
|
||||
WITH limites AS (
|
||||
SELECT
|
||||
CAST(? AS DATE) AS inicio,
|
||||
DATE_ADD(CAST(? AS DATE), INTERVAL 1 DAY) AS fim_exclusivo
|
||||
),
|
||||
q1 AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN pb.tipo_envio = 'balance' THEN COALESCE(pb.fee, 0)
|
||||
ELSE COALESCE(
|
||||
CASE WHEN pb.ptax IS NOT NULL AND pb.ptax > 0
|
||||
THEN ((pb.ptax - pb.cotacao) * pb.valor) / pb.ptax ELSE 0
|
||||
END, 0
|
||||
) + COALESCE(pb.fee, 0)
|
||||
END AS receita,
|
||||
'US→BR' AS direcao,
|
||||
COALESCE(pb.tipo_envio, 'desconhecido') AS tipo
|
||||
FROM pagamento_br pb
|
||||
JOIN limites l ON (
|
||||
CASE WHEN pb.tipo_envio = 'balance' THEN pb.data_cp ELSE pb.created_at END
|
||||
) >= l.inicio
|
||||
AND (
|
||||
CASE WHEN pb.tipo_envio = 'balance' THEN pb.data_cp ELSE pb.created_at END
|
||||
) < l.fim_exclusivo
|
||||
WHERE pb.valor > 0 AND pb.data_cp IS NOT NULL AND pb.data_cp <> '0000-00-00'
|
||||
),
|
||||
q2 AS (
|
||||
SELECT
|
||||
(
|
||||
(ROUND((t.amount_brl - IF(pm.provider IN ('ouribank','bs2'), 0, t.fee)) / t.ptax, 2) - COALESCE(t.pfee, 0))
|
||||
- (t.amount_usd + COALESCE(t.bonus_valor, 0) - COALESCE(t.taxa_cr, 0))
|
||||
) AS receita,
|
||||
'BR→US' AS direcao,
|
||||
CASE WHEN t.cobranca_id IS NOT NULL THEN 'Checkout' ELSE 'CambioTransfer' END AS tipo
|
||||
FROM br_transaction_to_usa t
|
||||
JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
JOIN limites l ON t.created_at >= l.inicio AND t.created_at < l.fim_exclusivo
|
||||
WHERE pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')
|
||||
AND t.ptax IS NOT NULL AND t.ptax > 0
|
||||
AND (t.status IN ('boleto_pago','finalizado') OR t.date_sent_usa <> '0000-00-00 00:00:00')
|
||||
)
|
||||
SELECT
|
||||
direcao,
|
||||
tipo,
|
||||
ROUND(SUM(receita), 2) AS total_receita,
|
||||
COUNT(*) AS total_qtd
|
||||
FROM (SELECT receita, direcao, tipo FROM q1 UNION ALL SELECT receita, direcao, tipo FROM q2) all_data
|
||||
GROUP BY direcao, tipo
|
||||
ORDER BY direcao, tipo
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
const timeline = rows.map(r => ({
|
||||
periodo_inicio: r.periodo_inicio instanceof Date ? r.periodo_inicio.toISOString().slice(0, 10) : String(r.periodo_inicio).slice(0, 10),
|
||||
periodo_label: r.periodo_label,
|
||||
produto: r.produto,
|
||||
receita: Number(r.receita),
|
||||
qtd: Number(r.qtd)
|
||||
}));
|
||||
|
||||
const totalsByProduct = totals.map(r => ({
|
||||
direcao: r.direcao,
|
||||
tipo: r.tipo,
|
||||
produto: r.direcao + ': ' + r.tipo,
|
||||
receita: Number(r.total_receita),
|
||||
qtd: Number(r.total_qtd)
|
||||
}));
|
||||
|
||||
const grandTotal = totalsByProduct.reduce((s, r) => s + r.receita, 0);
|
||||
const grandQtd = totalsByProduct.reduce((s, r) => s + r.qtd, 0);
|
||||
const receitaBrUs = totalsByProduct.filter(r => r.direcao === 'BR→US').reduce((s, r) => s + r.receita, 0);
|
||||
const receitaUsBr = totalsByProduct.filter(r => r.direcao === 'US→BR').reduce((s, r) => s + r.receita, 0);
|
||||
|
||||
return {
|
||||
timeline,
|
||||
totals: totalsByProduct,
|
||||
summary: {
|
||||
total_receita: Math.round(grandTotal * 100) / 100,
|
||||
total_qtd: grandQtd,
|
||||
receita_br_us: Math.round(receitaBrUs * 100) / 100,
|
||||
receita_us_br: Math.round(receitaUsBr * 100) / 100,
|
||||
ticket_medio_receita: grandQtd > 0 ? Math.round(grandTotal / grandQtd * 100) / 100 : 0
|
||||
},
|
||||
granularity: validGran
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBIStrategic(dataInicio, dataFim) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const { prevStartStr, prevEndStr } = calcPrevPeriod(dataInicio, dataFim);
|
||||
|
||||
// === 1. COHORT RETENTION ===
|
||||
const [cohortClients] = await conn.execute(`
|
||||
SELECT id_conta, DATE_FORMAT(MIN(first_op), '%Y-%m') as cohort_month FROM (
|
||||
SELECT id_conta, MIN(created_at) as first_op FROM br_transaction_to_usa GROUP BY id_conta
|
||||
UNION ALL
|
||||
SELECT id_conta, MIN(created_at) as first_op FROM pagamento_br
|
||||
WHERE cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY id_conta
|
||||
) f GROUP BY id_conta
|
||||
`);
|
||||
const [activeMonths] = await conn.execute(`
|
||||
SELECT id_conta, active_month FROM (
|
||||
SELECT id_conta, DATE_FORMAT(created_at, '%Y-%m') as active_month
|
||||
FROM br_transaction_to_usa GROUP BY id_conta, DATE_FORMAT(created_at, '%Y-%m')
|
||||
UNION
|
||||
SELECT id_conta, DATE_FORMAT(created_at, '%Y-%m') as active_month
|
||||
FROM pagamento_br WHERE cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY id_conta, DATE_FORMAT(created_at, '%Y-%m')
|
||||
) m
|
||||
`);
|
||||
|
||||
const clientCohort = {};
|
||||
cohortClients.forEach(r => { clientCohort[r.id_conta] = r.cohort_month; });
|
||||
const clientMonths = {};
|
||||
activeMonths.forEach(r => {
|
||||
if (!clientMonths[r.id_conta]) clientMonths[r.id_conta] = new Set();
|
||||
clientMonths[r.id_conta].add(r.active_month);
|
||||
});
|
||||
const allMonths = [...new Set([...cohortClients.map(r => r.cohort_month), ...activeMonths.map(r => r.active_month)])].sort();
|
||||
|
||||
const cohortMap = {};
|
||||
cohortClients.forEach(r => {
|
||||
const cm = r.cohort_month;
|
||||
if (!cohortMap[cm]) cohortMap[cm] = { size: 0, months: {} };
|
||||
cohortMap[cm].size++;
|
||||
});
|
||||
Object.keys(clientCohort).forEach(clientId => {
|
||||
const cm = clientCohort[clientId];
|
||||
const months = clientMonths[clientId] || new Set();
|
||||
const cmIdx = allMonths.indexOf(cm);
|
||||
months.forEach(am => {
|
||||
const amIdx = allMonths.indexOf(am);
|
||||
const offset = amIdx - cmIdx;
|
||||
if (offset >= 0) {
|
||||
if (!cohortMap[cm].months[offset]) cohortMap[cm].months[offset] = 0;
|
||||
cohortMap[cm].months[offset]++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const cohortKeys = Object.keys(cohortMap).sort().slice(-12);
|
||||
const cohorts = cohortKeys.map(cm => {
|
||||
const c = cohortMap[cm];
|
||||
const maxOff = allMonths.length - allMonths.indexOf(cm);
|
||||
const retention = [];
|
||||
for (let i = 0; i < Math.min(maxOff, 13); i++) {
|
||||
retention.push(c.size > 0 ? Math.round((c.months[i] || 0) / c.size * 100) : 0);
|
||||
}
|
||||
return { month: cm, size: c.size, retention };
|
||||
});
|
||||
|
||||
// === 2. REVENUE EXPANSION / CONTRACTION ===
|
||||
const [currRevenue] = await conn.execute(`
|
||||
SELECT id_conta, ROUND(SUM(revenue), 2) as revenue, ROUND(SUM(vol_usd), 2) as vol_usd FROM (
|
||||
SELECT id_conta, SUM((exchange_rate - ptax) / exchange_rate * amount_usd) as revenue, SUM(amount_usd) as vol_usd
|
||||
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
GROUP BY id_conta
|
||||
UNION ALL
|
||||
SELECT id_conta, SUM((ptax - cotacao) / ptax * valor) as revenue, SUM(valor) as vol_usd
|
||||
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY id_conta
|
||||
) c GROUP BY id_conta
|
||||
`, [dataInicio, dataFim, dataInicio, dataFim]);
|
||||
const [prevRevenue] = await conn.execute(`
|
||||
SELECT id_conta, ROUND(SUM(revenue), 2) as revenue, ROUND(SUM(vol_usd), 2) as vol_usd FROM (
|
||||
SELECT id_conta, SUM((exchange_rate - ptax) / exchange_rate * amount_usd) as revenue, SUM(amount_usd) as vol_usd
|
||||
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
GROUP BY id_conta
|
||||
UNION ALL
|
||||
SELECT id_conta, SUM((ptax - cotacao) / ptax * valor) as revenue, SUM(valor) as vol_usd
|
||||
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY id_conta
|
||||
) p GROUP BY id_conta
|
||||
`, [prevStartStr, prevEndStr, prevStartStr, prevEndStr]);
|
||||
|
||||
const currMap = {};
|
||||
currRevenue.forEach(r => { currMap[r.id_conta] = { revenue: Number(r.revenue), vol_usd: Number(r.vol_usd) }; });
|
||||
const prevMap = {};
|
||||
prevRevenue.forEach(r => { prevMap[r.id_conta] = { revenue: Number(r.revenue), vol_usd: Number(r.vol_usd) }; });
|
||||
|
||||
const allClientIds = new Set([...Object.keys(currMap), ...Object.keys(prevMap)]);
|
||||
const expansion = {
|
||||
new_clients: { count: 0, revenue: 0 },
|
||||
expansion: { count: 0, revenue: 0 },
|
||||
stable: { count: 0, revenue: 0 },
|
||||
contraction: { count: 0, revenue: 0 },
|
||||
churned: { count: 0, revenue: 0 }
|
||||
};
|
||||
allClientIds.forEach(id => {
|
||||
const curr = currMap[id];
|
||||
const prev = prevMap[id];
|
||||
if (curr && !prev) {
|
||||
expansion.new_clients.count++; expansion.new_clients.revenue += curr.revenue;
|
||||
} else if (!curr && prev) {
|
||||
expansion.churned.count++; expansion.churned.revenue -= Math.abs(prev.revenue);
|
||||
} else if (curr && prev) {
|
||||
const absP = Math.abs(prev.revenue);
|
||||
const change = absP > 0 ? (curr.revenue - prev.revenue) / absP : 0;
|
||||
if (change > 0.1) {
|
||||
expansion.expansion.count++; expansion.expansion.revenue += (curr.revenue - prev.revenue);
|
||||
} else if (change < -0.1) {
|
||||
expansion.contraction.count++; expansion.contraction.revenue += (curr.revenue - prev.revenue);
|
||||
} else {
|
||||
expansion.stable.count++; expansion.stable.revenue += curr.revenue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// === 3. CROSS-SELL ===
|
||||
const [crossSellData] = await conn.execute(`
|
||||
SELECT c.id_conta, t.vol_usd as pay_vol, p.vol_usd as checkout_vol
|
||||
FROM (
|
||||
SELECT DISTINCT id_conta FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
UNION
|
||||
SELECT DISTINCT id_conta FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
) c
|
||||
LEFT JOIN (
|
||||
SELECT id_conta, ROUND(SUM(amount_usd), 2) as vol_usd
|
||||
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
GROUP BY id_conta
|
||||
) t ON t.id_conta = c.id_conta
|
||||
LEFT JOIN (
|
||||
SELECT id_conta, ROUND(SUM(valor), 2) as vol_usd
|
||||
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY id_conta
|
||||
) p ON p.id_conta = c.id_conta
|
||||
`, [dataInicio, dataFim, dataInicio, dataFim, dataInicio, dataFim, dataInicio, dataFim]);
|
||||
|
||||
const crossSell = { pay_only: { count: 0, vol: 0 }, checkout_only: { count: 0, vol: 0 }, both: { count: 0, vol: 0 } };
|
||||
crossSellData.forEach(r => {
|
||||
const pv = Number(r.pay_vol) || 0;
|
||||
const cv = Number(r.checkout_vol) || 0;
|
||||
if (pv > 0 && cv > 0) { crossSell.both.count++; crossSell.both.vol += pv + cv; }
|
||||
else if (pv > 0) { crossSell.pay_only.count++; crossSell.pay_only.vol += pv; }
|
||||
else if (cv > 0) { crossSell.checkout_only.count++; crossSell.checkout_only.vol += cv; }
|
||||
});
|
||||
|
||||
// === 4. CLIENT MATURITY SEGMENTS ===
|
||||
const [maturityData] = await conn.execute(`
|
||||
SELECT id_conta,
|
||||
MIN(first_op) as first_op,
|
||||
FLOOR(DATEDIFF(CURDATE(), MIN(first_op)) / 30.44) as months_active,
|
||||
SUM(vol_usd) as lifetime_vol,
|
||||
SUM(CASE WHEN period = 'curr' THEN vol_usd ELSE 0 END) as curr_vol,
|
||||
SUM(CASE WHEN period = 'prev' THEN vol_usd ELSE 0 END) as prev_vol
|
||||
FROM (
|
||||
SELECT id_conta, MIN(created_at) as first_op, SUM(amount_usd) as vol_usd, 'all' as period
|
||||
FROM br_transaction_to_usa GROUP BY id_conta
|
||||
UNION ALL
|
||||
SELECT id_conta, MIN(created_at) as first_op, SUM(valor) as vol_usd, 'all' as period
|
||||
FROM pagamento_br WHERE cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY id_conta
|
||||
UNION ALL
|
||||
SELECT id_conta, NULL, SUM(amount_usd) as vol_usd, 'curr' as period
|
||||
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ? GROUP BY id_conta
|
||||
UNION ALL
|
||||
SELECT id_conta, NULL, SUM(valor) as vol_usd, 'curr' as period
|
||||
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY id_conta
|
||||
UNION ALL
|
||||
SELECT id_conta, NULL, SUM(amount_usd) as vol_usd, 'prev' as period
|
||||
FROM br_transaction_to_usa WHERE DATE(created_at) >= ? AND DATE(created_at) <= ? GROUP BY id_conta
|
||||
UNION ALL
|
||||
SELECT id_conta, NULL, SUM(valor) as vol_usd, 'prev' as period
|
||||
FROM pagamento_br WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY id_conta
|
||||
) combined GROUP BY id_conta
|
||||
`, [dataInicio, dataFim, dataInicio, dataFim, prevStartStr, prevEndStr, prevStartStr, prevEndStr]);
|
||||
|
||||
const maturity = { new_client: { count: 0, vol: 0 }, growing: { count: 0, vol: 0 }, mature: { count: 0, vol: 0 }, declining: { count: 0, vol: 0 } };
|
||||
maturityData.forEach(r => {
|
||||
const months = Number(r.months_active) || 0;
|
||||
const cv = Number(r.curr_vol) || 0;
|
||||
const pv = Number(r.prev_vol) || 0;
|
||||
const lv = Number(r.lifetime_vol) || 0;
|
||||
if (months < 3) {
|
||||
maturity.new_client.count++; maturity.new_client.vol += lv;
|
||||
} else if (pv > 0 && cv < pv * 0.85) {
|
||||
maturity.declining.count++; maturity.declining.vol += lv;
|
||||
} else if (months >= 12) {
|
||||
maturity.mature.count++; maturity.mature.vol += lv;
|
||||
} else {
|
||||
maturity.growing.count++; maturity.growing.vol += lv;
|
||||
}
|
||||
});
|
||||
|
||||
return { cohorts, expansion, crossSell, maturity };
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchBIData,
|
||||
fetchRevenueAnalytics,
|
||||
fetchBIStrategic
|
||||
};
|
||||
566
src/queries/client.queries.js
Normal file
566
src/queries/client.queries.js
Normal file
@@ -0,0 +1,566 @@
|
||||
/**
|
||||
* Client 360 Queries
|
||||
* Client profiles, search, transaction data, merchant checkout data
|
||||
*/
|
||||
const { pool, fmtDate, fmtDateTime, fmtTrendRows, calcPrevPeriod } = require('./helpers');
|
||||
|
||||
// Top 20 clients by total USD volume (including checkout volume for merchants)
|
||||
async function fetchTopClients() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.execute(`
|
||||
SELECT c.id_conta, c.nome,
|
||||
COALESCE(t1.vol_usd, 0) + COALESCE(t2.vol_usd, 0) + COALESCE(t3.vol_usd, 0) AS total_vol_usd,
|
||||
COALESCE(t1.cnt, 0) + COALESCE(t2.cnt, 0) + COALESCE(t3.cnt, 0) AS total_ops,
|
||||
GREATEST(COALESCE(t1.months_active, 0), COALESCE(t2.months_active, 0), COALESCE(t3.months_active, 0)) AS months_active,
|
||||
GREATEST(COALESCE(t1.last_op, '1970-01-01'), COALESCE(t2.last_op, '1970-01-01'), COALESCE(t3.last_op, '1970-01-01')) AS last_op
|
||||
FROM conta c
|
||||
LEFT JOIN (
|
||||
SELECT id_conta,
|
||||
SUM(amount_usd) AS vol_usd,
|
||||
COUNT(*) AS cnt,
|
||||
COUNT(DISTINCT DATE_FORMAT(created_at, '%Y-%m')) AS months_active,
|
||||
MAX(created_at) AS last_op
|
||||
FROM br_transaction_to_usa GROUP BY id_conta
|
||||
) t1 ON t1.id_conta = c.id_conta
|
||||
LEFT JOIN (
|
||||
SELECT id_conta,
|
||||
SUM(valor / cotacao) AS vol_usd,
|
||||
COUNT(*) AS cnt,
|
||||
COUNT(DISTINCT DATE_FORMAT(created_at, '%Y-%m')) AS months_active,
|
||||
MAX(created_at) AS last_op
|
||||
FROM pagamento_br GROUP BY id_conta
|
||||
) t2 ON t2.id_conta = c.id_conta
|
||||
LEFT JOIN (
|
||||
SELECT e.id_conta,
|
||||
SUM(t.amount_usd) AS vol_usd,
|
||||
COUNT(*) AS cnt,
|
||||
COUNT(DISTINCT DATE_FORMAT(t.created_at, '%Y-%m')) AS months_active,
|
||||
MAX(t.created_at) AS last_op
|
||||
FROM br_cb_empresas e
|
||||
INNER JOIN br_cb_cobranca cb ON cb.empresa_id = e.id
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
WHERE e.active = 1
|
||||
GROUP BY e.id_conta
|
||||
) t3 ON t3.id_conta = c.id_conta
|
||||
WHERE COALESCE(t1.cnt, 0) + COALESCE(t2.cnt, 0) + COALESCE(t3.cnt, 0) > 0
|
||||
ORDER BY total_vol_usd DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
return rows.map(r => ({
|
||||
id: r.id_conta,
|
||||
nome: r.nome,
|
||||
vol: Math.round(r.total_vol_usd || 0),
|
||||
ops: r.total_ops || 0,
|
||||
months: r.months_active || 0,
|
||||
lastOp: r.last_op && String(r.last_op) !== '1970-01-01' ? (r.last_op instanceof Date ? r.last_op.toISOString().slice(0, 10) : String(r.last_op).slice(0, 10)) : null
|
||||
}));
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Search clients by name (server-side, max 15 results) — includes merchants
|
||||
async function fetchClientSearch(query) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.execute(`
|
||||
SELECT c.id_conta, c.nome FROM conta c
|
||||
WHERE c.id_conta IN (
|
||||
SELECT DISTINCT id_conta FROM br_transaction_to_usa
|
||||
UNION SELECT DISTINCT id_conta FROM pagamento_br
|
||||
UNION SELECT DISTINCT id_conta FROM br_cb_empresas WHERE active = 1
|
||||
) AND c.nome LIKE CONCAT('%', ?, '%')
|
||||
ORDER BY c.nome LIMIT 15
|
||||
`, [query]);
|
||||
return rows.map(r => ({ id: r.id_conta, nome: r.nome }));
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Client lifetime profile (no date filter)
|
||||
async function fetchClientProfile(clienteId) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [conta] = await conn.execute('SELECT nome FROM conta WHERE id_conta = ?', [clienteId]);
|
||||
const nome = conta[0]?.nome || 'Cliente #' + clienteId;
|
||||
|
||||
const [brl] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
||||
ROUND(COALESCE(SUM(amount_brl),0),2) as vol_brl,
|
||||
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),0),2) as spread_revenue,
|
||||
MIN(created_at) as first_op, MAX(created_at) as last_op
|
||||
FROM br_transaction_to_usa WHERE id_conta = ?
|
||||
`, [clienteId]);
|
||||
|
||||
const [usd] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
||||
ROUND(COALESCE(SUM(valor_sol),0),2) as vol_brl,
|
||||
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor),0),2) as spread_revenue,
|
||||
MIN(created_at) as first_op, MAX(created_at) as last_op
|
||||
FROM pagamento_br WHERE id_conta = ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0
|
||||
AND (pgto IS NULL OR pgto != 'balance')
|
||||
`, [clienteId]);
|
||||
|
||||
const brlData = brl[0] || {};
|
||||
const usdData = usd[0] || {};
|
||||
|
||||
const dates = [brlData.first_op, usdData.first_op].filter(Boolean);
|
||||
const lastDates = [brlData.last_op, usdData.last_op].filter(Boolean);
|
||||
const firstOp = dates.length > 0 ? new Date(Math.min(...dates.map(d => new Date(d).getTime()))) : null;
|
||||
const lastOp = lastDates.length > 0 ? new Date(Math.max(...lastDates.map(d => new Date(d).getTime()))) : null;
|
||||
const daysInactive = lastOp ? Math.round((Date.now() - lastOp.getTime()) / 86400000) : null;
|
||||
|
||||
const brlQtd = Number(brlData.qtd) || 0;
|
||||
const usdQtd = Number(usdData.qtd) || 0;
|
||||
const totalOps = brlQtd + usdQtd;
|
||||
const totalVolUsd = (Number(brlData.vol_usd) || 0) + (Number(usdData.vol_usd) || 0);
|
||||
const totalSpreadRev = (Number(brlData.spread_revenue) || 0) + (Number(usdData.spread_revenue) || 0);
|
||||
|
||||
const [monthsRows] = await conn.execute(`
|
||||
SELECT COUNT(DISTINCT mes) as months_active FROM (
|
||||
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes FROM br_transaction_to_usa WHERE id_conta = ?
|
||||
UNION
|
||||
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes FROM pagamento_br WHERE id_conta = ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
) m
|
||||
`, [clienteId, clienteId]);
|
||||
const monthsActive = Number(monthsRows[0]?.months_active) || 0;
|
||||
|
||||
return {
|
||||
id: clienteId,
|
||||
nome,
|
||||
first_op: firstOp ? firstOp.toISOString().slice(0, 10) : null,
|
||||
last_op: lastOp ? lastOp.toISOString().slice(0, 10) : null,
|
||||
days_inactive: daysInactive,
|
||||
total_ops: totalOps,
|
||||
total_vol_usd: totalVolUsd,
|
||||
total_vol_brl: (Number(brlData.vol_brl) || 0) + (Number(usdData.vol_brl) || 0),
|
||||
total_spread_revenue: totalSpreadRev,
|
||||
months_active: monthsActive,
|
||||
avg_monthly_vol: monthsActive > 0 ? Math.round(totalVolUsd / monthsActive) : 0,
|
||||
avg_monthly_ops: monthsActive > 0 ? Math.round(totalOps / monthsActive * 10) / 10 : 0,
|
||||
avg_monthly_revenue: monthsActive > 0 ? Math.round(totalSpreadRev / monthsActive * 100) / 100 : 0,
|
||||
ltv: totalSpreadRev,
|
||||
brlUsd: { qtd: brlQtd, vol_usd: Number(brlData.vol_usd) || 0 },
|
||||
usdBrl: { qtd: usdQtd, vol_usd: Number(usdData.vol_usd) || 0 }
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Client data for a period — full analytics
|
||||
async function fetchClientData(clienteId, dataInicio, dataFim) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const { prevStartStr, prevEndStr } = calcPrevPeriod(dataInicio, dataFim);
|
||||
|
||||
// KPIs BRL->USD
|
||||
const [kpiBrl] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
||||
ROUND(COALESCE(SUM(amount_brl),0),2) as vol_brl,
|
||||
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),0),2) as spread_revenue,
|
||||
ROUND(COALESCE(AVG((exchange_rate - ptax) / exchange_rate * 100),0),2) as avg_spread_pct
|
||||
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
`, [clienteId, dataInicio, dataFim]);
|
||||
|
||||
// KPIs USD->BRL
|
||||
const [kpiUsd] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
||||
ROUND(COALESCE(SUM(valor_sol),0),2) as vol_brl,
|
||||
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor),0),2) as spread_revenue,
|
||||
ROUND(COALESCE(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END),0),2) as avg_spread_pct
|
||||
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
`, [clienteId, dataInicio, dataFim]);
|
||||
|
||||
// Previous period
|
||||
const [prevBrl] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
|
||||
ROUND(COALESCE(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),0),2) as spread_revenue
|
||||
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
`, [clienteId, prevStartStr, prevEndStr]);
|
||||
const [prevUsd] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
|
||||
ROUND(COALESCE(SUM((ptax - cotacao) / ptax * valor),0),2) as spread_revenue
|
||||
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
`, [clienteId, prevStartStr, prevEndStr]);
|
||||
|
||||
// Trend BRL->USD
|
||||
const [trendBrl] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(amount_usd),2) as vol_usd,
|
||||
ROUND(AVG((exchange_rate - ptax) / exchange_rate * 100),2) as avg_spread
|
||||
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`, [clienteId, dataInicio, dataFim]);
|
||||
|
||||
// Trend USD->BRL
|
||||
const [trendUsd] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(valor),2) as vol_usd,
|
||||
ROUND(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END),2) as avg_spread
|
||||
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`, [clienteId, dataInicio, dataFim]);
|
||||
|
||||
// Individual transactions BRL->USD
|
||||
const [txBrl] = await conn.execute(`
|
||||
SELECT t.created_at as date, 'BRL→USD' as flow,
|
||||
t.amount_usd as usd, t.amount_brl as brl,
|
||||
ROUND(t.exchange_rate,4) as rate, ROUND(t.ptax,4) as ptax,
|
||||
ROUND((t.exchange_rate - t.ptax) / t.exchange_rate * 100, 2) as spread_pct,
|
||||
t.iof, t.status,
|
||||
COALESCE(pm.provider, '') as provider
|
||||
FROM br_transaction_to_usa t
|
||||
LEFT JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
WHERE t.id_conta = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
ORDER BY t.created_at DESC
|
||||
`, [clienteId, dataInicio, dataFim]);
|
||||
|
||||
// Individual transactions USD->BRL
|
||||
const [txUsd] = await conn.execute(`
|
||||
SELECT p.created_at as date, 'USD→BRL' as flow,
|
||||
p.valor as usd, ROUND(p.valor * p.cotacao, 2) as brl,
|
||||
ROUND(p.cotacao,4) as rate, ROUND(p.ptax,4) as ptax,
|
||||
CASE WHEN p.cotacao > 0 THEN ROUND((p.ptax - p.cotacao) / p.ptax * 100, 2) ELSE 0 END as spread_pct,
|
||||
0 as iof, COALESCE(p.pgto, '') as status,
|
||||
COALESCE(p.tipo_envio, '') as provider
|
||||
FROM pagamento_br p
|
||||
WHERE p.id_conta = ? AND DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
||||
AND p.cotacao IS NOT NULL AND p.cotacao > 0 AND (p.pgto IS NULL OR p.pgto != 'balance')
|
||||
ORDER BY p.created_at DESC
|
||||
`, [clienteId, dataInicio, dataFim]);
|
||||
|
||||
// Day of week
|
||||
const [dowBrl] = await conn.execute(`
|
||||
SELECT DAYOFWEEK(created_at) as dow, COUNT(*) as qtd, ROUND(SUM(amount_usd),2) as vol_usd
|
||||
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
GROUP BY DAYOFWEEK(created_at)
|
||||
`, [clienteId, dataInicio, dataFim]);
|
||||
const [dowUsd] = await conn.execute(`
|
||||
SELECT DAYOFWEEK(created_at) as dow, COUNT(*) as qtd, ROUND(SUM(valor),2) as vol_usd
|
||||
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY DAYOFWEEK(created_at)
|
||||
`, [clienteId, dataInicio, dataFim]);
|
||||
|
||||
// Providers
|
||||
const [provBrl] = await conn.execute(`
|
||||
SELECT COALESCE(pm.provider, 'N/A') as name, COUNT(*) as qtd, ROUND(SUM(t.amount_usd),2) as vol_usd
|
||||
FROM br_transaction_to_usa t
|
||||
LEFT JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
WHERE t.id_conta = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
GROUP BY pm.provider
|
||||
`, [clienteId, dataInicio, dataFim]);
|
||||
const [provUsd] = await conn.execute(`
|
||||
SELECT COALESCE(p.tipo_envio, 'N/A') as name, COUNT(*) as qtd, ROUND(SUM(p.valor),2) as vol_usd
|
||||
FROM pagamento_br p
|
||||
WHERE p.id_conta = ? AND DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
||||
AND p.cotacao IS NOT NULL AND p.cotacao > 0 AND (p.pgto IS NULL OR p.pgto != 'balance')
|
||||
GROUP BY p.tipo_envio
|
||||
`, [clienteId, dataInicio, dataFim]);
|
||||
|
||||
// Monthly breakdown
|
||||
const [monthlyBrl] = await conn.execute(`
|
||||
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, COUNT(*) as qtd,
|
||||
ROUND(SUM(amount_usd),2) as vol_usd,
|
||||
ROUND(SUM((exchange_rate - ptax) / exchange_rate * amount_usd),2) as spread_revenue
|
||||
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY mes
|
||||
`, [clienteId, dataInicio, dataFim]);
|
||||
const [monthlyUsd] = await conn.execute(`
|
||||
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, COUNT(*) as qtd,
|
||||
ROUND(SUM(valor),2) as vol_usd,
|
||||
ROUND(SUM((ptax - cotacao) / ptax * valor),2) as spread_revenue
|
||||
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY mes
|
||||
`, [clienteId, dataInicio, dataFim]);
|
||||
|
||||
const b = kpiBrl[0] || {};
|
||||
const u = kpiUsd[0] || {};
|
||||
const bQtd = Number(b.qtd) || 0;
|
||||
const uQtd = Number(u.qtd) || 0;
|
||||
const totalQtd = bQtd + uQtd;
|
||||
const totalVolUsd = (Number(b.vol_usd) || 0) + (Number(u.vol_usd) || 0);
|
||||
|
||||
// Merge day of week
|
||||
const dowMap = {};
|
||||
for (let i = 1; i <= 7; i++) dowMap[i] = { qtd: 0, vol_usd: 0 };
|
||||
dowBrl.forEach(r => { dowMap[r.dow].qtd += Number(r.qtd); dowMap[r.dow].vol_usd += Number(r.vol_usd); });
|
||||
dowUsd.forEach(r => { dowMap[r.dow].qtd += Number(r.qtd); dowMap[r.dow].vol_usd += Number(r.vol_usd); });
|
||||
|
||||
// Merge providers
|
||||
const provMap = {};
|
||||
[...provBrl, ...provUsd].forEach(r => {
|
||||
const n = r.name || 'N/A';
|
||||
if (!provMap[n]) provMap[n] = { name: n, qtd: 0, vol_usd: 0 };
|
||||
provMap[n].qtd += Number(r.qtd);
|
||||
provMap[n].vol_usd += Number(r.vol_usd);
|
||||
});
|
||||
|
||||
// Merge monthly data
|
||||
const monthMap = {};
|
||||
[...monthlyBrl, ...monthlyUsd].forEach(r => {
|
||||
if (!monthMap[r.mes]) monthMap[r.mes] = { mes: r.mes, qtd: 0, vol_usd: 0, spread_revenue: 0 };
|
||||
monthMap[r.mes].qtd += Number(r.qtd);
|
||||
monthMap[r.mes].vol_usd += Number(r.vol_usd);
|
||||
monthMap[r.mes].spread_revenue += Number(r.spread_revenue);
|
||||
});
|
||||
|
||||
const transactions = [
|
||||
...txBrl.map(r => ({
|
||||
date: fmtDateTime(r.date), flow: r.flow, usd: Number(r.usd), brl: Number(r.brl),
|
||||
rate: Number(r.rate), ptax: Number(r.ptax), spread_pct: Number(r.spread_pct),
|
||||
iof: Number(r.iof), status: r.status, provider: r.provider
|
||||
})),
|
||||
...txUsd.map(r => ({
|
||||
date: fmtDateTime(r.date), flow: r.flow, usd: Number(r.usd), brl: Number(r.brl),
|
||||
rate: Number(r.rate), ptax: Number(r.ptax), spread_pct: Number(r.spread_pct),
|
||||
iof: Number(r.iof), status: r.status, provider: r.provider
|
||||
}))
|
||||
].sort((a, b) => b.date.localeCompare(a.date));
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
brlUsd: { qtd: bQtd, vol_usd: Number(b.vol_usd)||0, vol_brl: Number(b.vol_brl)||0, spread_revenue: Number(b.spread_revenue)||0, avg_spread_pct: Number(b.avg_spread_pct)||0, ticket_medio: bQtd > 0 ? Math.round((Number(b.vol_usd)||0) / bQtd) : 0 },
|
||||
usdBrl: { qtd: uQtd, vol_usd: Number(u.vol_usd)||0, vol_brl: Number(u.vol_brl)||0, spread_revenue: Number(u.spread_revenue)||0, avg_spread_pct: Number(u.avg_spread_pct)||0, ticket_medio: uQtd > 0 ? Math.round((Number(u.vol_usd)||0) / uQtd) : 0 },
|
||||
total: {
|
||||
qtd: totalQtd, vol_usd: totalVolUsd, vol_brl: (Number(b.vol_brl)||0) + (Number(u.vol_brl)||0),
|
||||
spread_revenue: (Number(b.spread_revenue)||0) + (Number(u.spread_revenue)||0),
|
||||
avg_spread_pct: totalQtd > 0 ? ((Number(b.avg_spread_pct)||0) * bQtd + (Number(u.avg_spread_pct)||0) * uQtd) / totalQtd : 0,
|
||||
ticket_medio: totalQtd > 0 ? Math.round(totalVolUsd / totalQtd) : 0
|
||||
}
|
||||
},
|
||||
comparison: {
|
||||
prev_qtd: (Number(prevBrl[0]?.qtd)||0) + (Number(prevUsd[0]?.qtd)||0),
|
||||
prev_vol_usd: (Number(prevBrl[0]?.vol_usd)||0) + (Number(prevUsd[0]?.vol_usd)||0),
|
||||
prev_spread: (Number(prevBrl[0]?.spread_revenue)||0) + (Number(prevUsd[0]?.spread_revenue)||0)
|
||||
},
|
||||
trend: { brlUsd: fmtTrendRows(trendBrl), usdBrl: fmtTrendRows(trendUsd) },
|
||||
transactions,
|
||||
dayOfWeek: dowMap,
|
||||
providers: Object.values(provMap).sort((a, b) => b.vol_usd - a.vol_usd),
|
||||
monthly: Object.values(monthMap).map(m => ({ mes: m.mes, qtd: m.qtd, vol_usd: Math.round(m.vol_usd * 100) / 100, spread_revenue: Math.round(m.spread_revenue * 100) / 100, avg_usd: m.qtd > 0 ? Math.round(m.vol_usd / m.qtd) : 0 })).sort((a, b) => a.mes.localeCompare(b.mes))
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if client is a merchant
|
||||
async function fetchMerchantProfile(clienteId) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [empresa] = await conn.execute(
|
||||
'SELECT id, nome_empresa FROM br_cb_empresas WHERE id_conta = ? AND active = 1 LIMIT 1',
|
||||
[clienteId]
|
||||
);
|
||||
if (!empresa.length) return { is_merchant: false };
|
||||
|
||||
const empresaId = empresa[0].id;
|
||||
const nomeEmpresa = empresa[0].nome_empresa;
|
||||
|
||||
const [stats] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as tx_count,
|
||||
ROUND(COALESCE(SUM(t.amount_usd), 0), 2) as vol_usd,
|
||||
COUNT(DISTINCT t.id_conta) as unique_payers,
|
||||
MIN(t.created_at) as first_op,
|
||||
MAX(t.created_at) as last_op,
|
||||
COUNT(DISTINCT DATE_FORMAT(t.created_at, '%Y-%m')) as months_active
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
WHERE cb.empresa_id = ?
|
||||
`, [empresaId]);
|
||||
|
||||
const [rev] = await conn.execute(`
|
||||
SELECT ROUND(COALESCE(SUM(
|
||||
(
|
||||
ROUND((t.amount_brl - IF(pm.provider IN ('ouribank','bs2'), 0, t.fee)) / t.ptax, 2)
|
||||
- COALESCE(t.pfee, 0)
|
||||
) - (
|
||||
t.amount_usd + COALESCE(t.bonus_valor, 0) - COALESCE(t.taxa_cr, 0)
|
||||
)
|
||||
), 0), 2) as revenue
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
INNER JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
WHERE cb.empresa_id = ?
|
||||
AND pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')
|
||||
AND t.ptax IS NOT NULL AND t.ptax > 0
|
||||
AND (t.status IN ('boleto_pago','finalizado') OR t.date_sent_usa <> '0000-00-00 00:00:00')
|
||||
`, [empresaId]);
|
||||
|
||||
const s = stats[0] || {};
|
||||
return {
|
||||
is_merchant: true,
|
||||
empresa_id: empresaId,
|
||||
nome_empresa: nomeEmpresa,
|
||||
checkout: {
|
||||
tx_count: Number(s.tx_count) || 0,
|
||||
vol_usd: Number(s.vol_usd) || 0,
|
||||
unique_payers: Number(s.unique_payers) || 0,
|
||||
revenue: Number(rev[0]?.revenue) || 0,
|
||||
first_op: s.first_op ? (s.first_op instanceof Date ? s.first_op.toISOString().slice(0, 10) : String(s.first_op).slice(0, 10)) : null,
|
||||
last_op: s.last_op ? (s.last_op instanceof Date ? s.last_op.toISOString().slice(0, 10) : String(s.last_op).slice(0, 10)) : null,
|
||||
months_active: Number(s.months_active) || 0
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Merchant checkout data for a period
|
||||
async function fetchMerchantData(empresaId, dataInicio, dataFim) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const { prevStartStr, prevEndStr } = calcPrevPeriod(dataInicio, dataFim);
|
||||
|
||||
const [kpi] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as qtd,
|
||||
ROUND(COALESCE(SUM(t.amount_usd), 0), 2) as vol_usd,
|
||||
COUNT(DISTINCT t.id_conta) as unique_payers,
|
||||
ROUND(COALESCE(AVG((t.exchange_rate - t.ptax) / t.exchange_rate * 100), 0), 2) as avg_spread_pct
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
`, [empresaId, dataInicio, dataFim]);
|
||||
|
||||
const [rev] = await conn.execute(`
|
||||
SELECT ROUND(COALESCE(SUM(
|
||||
(
|
||||
ROUND((t.amount_brl - IF(pm.provider IN ('ouribank','bs2'), 0, t.fee)) / t.ptax, 2)
|
||||
- COALESCE(t.pfee, 0)
|
||||
) - (
|
||||
t.amount_usd + COALESCE(t.bonus_valor, 0) - COALESCE(t.taxa_cr, 0)
|
||||
)
|
||||
), 0), 2) as revenue
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
INNER JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
AND pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')
|
||||
AND t.ptax IS NOT NULL AND t.ptax > 0
|
||||
AND (t.status IN ('boleto_pago','finalizado') OR t.date_sent_usa <> '0000-00-00 00:00:00')
|
||||
`, [empresaId, dataInicio, dataFim]);
|
||||
|
||||
const [prevKpi] = await conn.execute(`
|
||||
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(t.amount_usd), 0), 2) as vol_usd
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
`, [empresaId, prevStartStr, prevEndStr]);
|
||||
|
||||
const [prevRev] = await conn.execute(`
|
||||
SELECT ROUND(COALESCE(SUM(
|
||||
(
|
||||
ROUND((t.amount_brl - IF(pm.provider IN ('ouribank','bs2'), 0, t.fee)) / t.ptax, 2)
|
||||
- COALESCE(t.pfee, 0)
|
||||
) - (
|
||||
t.amount_usd + COALESCE(t.bonus_valor, 0) - COALESCE(t.taxa_cr, 0)
|
||||
)
|
||||
), 0), 2) as revenue
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
INNER JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
AND pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')
|
||||
AND t.ptax IS NOT NULL AND t.ptax > 0
|
||||
AND (t.status IN ('boleto_pago','finalizado') OR t.date_sent_usa <> '0000-00-00 00:00:00')
|
||||
`, [empresaId, prevStartStr, prevEndStr]);
|
||||
|
||||
const [monthly] = await conn.execute(`
|
||||
SELECT DATE_FORMAT(t.created_at, '%Y-%m') as mes,
|
||||
COUNT(*) as qtd,
|
||||
ROUND(SUM(t.amount_usd), 2) as vol_usd,
|
||||
COUNT(DISTINCT t.id_conta) as unique_payers
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
GROUP BY DATE_FORMAT(t.created_at, '%Y-%m') ORDER BY mes
|
||||
`, [empresaId, dataInicio, dataFim]);
|
||||
|
||||
const [topPayers] = await conn.execute(`
|
||||
SELECT c.nome, t.id_conta, COUNT(*) as tx_count, ROUND(SUM(t.amount_usd), 2) as vol_usd
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
GROUP BY t.id_conta, c.nome
|
||||
ORDER BY vol_usd DESC LIMIT 10
|
||||
`, [empresaId, dataInicio, dataFim]);
|
||||
|
||||
const [txRows] = await conn.execute(`
|
||||
SELECT t.created_at as date, t.amount_usd as usd, t.amount_brl as brl,
|
||||
ROUND(t.exchange_rate, 4) as rate, ROUND(t.ptax, 4) as ptax,
|
||||
ROUND((t.exchange_rate - t.ptax) / t.exchange_rate * 100, 2) as spread_pct,
|
||||
t.iof, t.status, COALESCE(pm.provider, '') as provider,
|
||||
c.nome as payer_name, t.id_conta as payer_id
|
||||
FROM br_cb_cobranca cb
|
||||
INNER JOIN br_transaction_to_usa t ON t.cobranca_id = cb.id
|
||||
LEFT JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
LEFT JOIN conta c ON c.id_conta = t.id_conta
|
||||
WHERE cb.empresa_id = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
ORDER BY t.created_at DESC LIMIT 500
|
||||
`, [empresaId, dataInicio, dataFim]);
|
||||
|
||||
const k = kpi[0] || {};
|
||||
const qtd = Number(k.qtd) || 0;
|
||||
const volUsd = Number(k.vol_usd) || 0;
|
||||
const revenue = Number(rev[0]?.revenue) || 0;
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
qtd,
|
||||
vol_usd: volUsd,
|
||||
unique_payers: Number(k.unique_payers) || 0,
|
||||
avg_spread_pct: Number(k.avg_spread_pct) || 0,
|
||||
revenue,
|
||||
ticket_medio: qtd > 0 ? Math.round(volUsd / qtd) : 0
|
||||
},
|
||||
comparison: {
|
||||
prev_qtd: Number(prevKpi[0]?.qtd) || 0,
|
||||
prev_vol_usd: Number(prevKpi[0]?.vol_usd) || 0,
|
||||
prev_revenue: Number(prevRev[0]?.revenue) || 0
|
||||
},
|
||||
monthly: monthly.map(r => ({
|
||||
mes: r.mes,
|
||||
qtd: Number(r.qtd),
|
||||
vol_usd: Number(r.vol_usd),
|
||||
unique_payers: Number(r.unique_payers)
|
||||
})),
|
||||
topPayers: topPayers.map(r => ({
|
||||
nome: r.nome,
|
||||
id_conta: r.id_conta,
|
||||
tx_count: Number(r.tx_count),
|
||||
vol_usd: Number(r.vol_usd)
|
||||
})),
|
||||
transactions: txRows.map(r => ({
|
||||
date: fmtDateTime(r.date), flow: 'Checkout', usd: Number(r.usd), brl: Number(r.brl),
|
||||
rate: Number(r.rate), ptax: Number(r.ptax), spread_pct: Number(r.spread_pct),
|
||||
iof: Number(r.iof), status: r.status, provider: r.provider,
|
||||
payer_name: r.payer_name || '', payer_id: r.payer_id
|
||||
}))
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchTopClients,
|
||||
fetchClientSearch,
|
||||
fetchClientProfile,
|
||||
fetchClientData,
|
||||
fetchMerchantProfile,
|
||||
fetchMerchantData
|
||||
};
|
||||
179
src/queries/compliance.queries.js
Normal file
179
src/queries/compliance.queries.js
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Compliance Queries — AML/KYC monitoring data layer
|
||||
* Dashboard deferred to backlog; queries available for alerting and future UI
|
||||
*/
|
||||
const { pool, fmtDate, fmtDateTime } = require('./helpers');
|
||||
|
||||
// Transactions above $3,000 threshold
|
||||
async function fetchThresholdTransactions(start, end) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [brlUsd] = await conn.execute(`
|
||||
SELECT c.nome, t.id_conta, t.created_at as date, t.amount_usd as usd,
|
||||
t.amount_brl as brl, 'BRL→USD' as flow
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
AND t.amount_usd >= 3000
|
||||
ORDER BY t.amount_usd DESC
|
||||
`, [start, end]);
|
||||
|
||||
const [usdBrl] = await conn.execute(`
|
||||
SELECT c.nome, p.id_conta, p.created_at as date, p.valor as usd,
|
||||
ROUND(p.valor * p.cotacao, 2) as brl, 'USD→BRL' as flow
|
||||
FROM pagamento_br p
|
||||
INNER JOIN conta c ON c.id_conta = p.id_conta
|
||||
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
||||
AND p.valor >= 3000
|
||||
AND p.cotacao IS NOT NULL AND p.cotacao > 0
|
||||
AND (p.pgto IS NULL OR p.pgto != 'balance')
|
||||
ORDER BY p.valor DESC
|
||||
`, [start, end]);
|
||||
|
||||
return [...brlUsd, ...usdBrl].map(r => ({
|
||||
nome: r.nome,
|
||||
id_conta: r.id_conta,
|
||||
date: fmtDateTime(r.date),
|
||||
usd: Number(r.usd),
|
||||
brl: Number(r.brl),
|
||||
flow: r.flow
|
||||
})).sort((a, b) => b.usd - a.usd);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Structuring alerts: multiple txs $2,500-$3,000 same client/day
|
||||
async function fetchStructuringAlerts(start, end) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [brlUsd] = await conn.execute(`
|
||||
SELECT c.nome, t.id_conta, DATE(t.created_at) as date,
|
||||
COUNT(*) as tx_count,
|
||||
ROUND(SUM(t.amount_usd), 2) as total_usd,
|
||||
'BRL→USD' as flow
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
AND t.amount_usd BETWEEN 2500 AND 3000
|
||||
GROUP BY t.id_conta, c.nome, DATE(t.created_at)
|
||||
HAVING COUNT(*) >= 2
|
||||
ORDER BY tx_count DESC
|
||||
`, [start, end]);
|
||||
|
||||
const [usdBrl] = await conn.execute(`
|
||||
SELECT c.nome, p.id_conta, DATE(p.created_at) as date,
|
||||
COUNT(*) as tx_count,
|
||||
ROUND(SUM(p.valor), 2) as total_usd,
|
||||
'USD→BRL' as flow
|
||||
FROM pagamento_br p
|
||||
INNER JOIN conta c ON c.id_conta = p.id_conta
|
||||
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
||||
AND p.valor BETWEEN 2500 AND 3000
|
||||
AND p.cotacao IS NOT NULL AND p.cotacao > 0
|
||||
AND (p.pgto IS NULL OR p.pgto != 'balance')
|
||||
GROUP BY p.id_conta, c.nome, DATE(p.created_at)
|
||||
HAVING COUNT(*) >= 2
|
||||
ORDER BY tx_count DESC
|
||||
`, [start, end]);
|
||||
|
||||
return [...brlUsd, ...usdBrl].map(r => ({
|
||||
nome: r.nome,
|
||||
id_conta: r.id_conta,
|
||||
date: fmtDate(r.date),
|
||||
tx_count: Number(r.tx_count),
|
||||
total_usd: Number(r.total_usd),
|
||||
flow: r.flow
|
||||
})).sort((a, b) => b.tx_count - a.tx_count);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Velocity anomalies: frequency spikes vs 30-day avg
|
||||
async function fetchVelocityAnomalies(start, end) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.execute(`
|
||||
SELECT c.nome, curr.id_conta,
|
||||
curr.daily_count as current_daily_count,
|
||||
curr.date as spike_date,
|
||||
ROUND(avg30.avg_daily, 1) as avg_daily_30d,
|
||||
ROUND(curr.daily_count / GREATEST(avg30.avg_daily, 1), 1) as multiple
|
||||
FROM (
|
||||
SELECT id_conta, DATE(created_at) as date, COUNT(*) as daily_count
|
||||
FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
GROUP BY id_conta, DATE(created_at)
|
||||
HAVING COUNT(*) >= 3
|
||||
) curr
|
||||
INNER JOIN (
|
||||
SELECT id_conta, COUNT(*) / 30.0 as avg_daily
|
||||
FROM br_transaction_to_usa
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||
AND created_at < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY id_conta
|
||||
) avg30 ON avg30.id_conta = curr.id_conta
|
||||
INNER JOIN conta c ON c.id_conta = curr.id_conta
|
||||
WHERE curr.daily_count > avg30.avg_daily * 3
|
||||
ORDER BY multiple DESC
|
||||
LIMIT 50
|
||||
`, [start, end]);
|
||||
|
||||
return rows.map(r => ({
|
||||
nome: r.nome,
|
||||
id_conta: r.id_conta,
|
||||
spike_date: fmtDate(r.spike_date),
|
||||
current_daily_count: Number(r.current_daily_count),
|
||||
avg_daily_30d: Number(r.avg_daily_30d),
|
||||
multiple: Number(r.multiple)
|
||||
}));
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// IOF reconciliation: calculated vs expected IOF delta
|
||||
async function fetchIOFReconciliation(start, end) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.execute(`
|
||||
SELECT
|
||||
c.nome, t.id_conta, t.created_at as date,
|
||||
t.amount_usd, t.iof as iof_pct,
|
||||
ROUND(t.iof / 100 * t.amount_usd * t.exchange_rate, 2) as iof_calculated,
|
||||
ROUND(t.amount_brl - (t.amount_usd * t.exchange_rate), 2) as iof_implied,
|
||||
ROUND(
|
||||
ABS((t.iof / 100 * t.amount_usd * t.exchange_rate) - (t.amount_brl - (t.amount_usd * t.exchange_rate))),
|
||||
2
|
||||
) as delta
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
AND t.iof > 0
|
||||
HAVING delta > 10
|
||||
ORDER BY delta DESC
|
||||
LIMIT 100
|
||||
`, [start, end]);
|
||||
|
||||
return rows.map(r => ({
|
||||
nome: r.nome,
|
||||
id_conta: r.id_conta,
|
||||
date: fmtDateTime(r.date),
|
||||
amount_usd: Number(r.amount_usd),
|
||||
iof_pct: Number(r.iof_pct),
|
||||
iof_calculated: Number(r.iof_calculated),
|
||||
iof_implied: Number(r.iof_implied),
|
||||
delta: Number(r.delta)
|
||||
}));
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchThresholdTransactions,
|
||||
fetchStructuringAlerts,
|
||||
fetchVelocityAnomalies,
|
||||
fetchIOFReconciliation
|
||||
};
|
||||
351
src/queries/corporate.queries.js
Normal file
351
src/queries/corporate.queries.js
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Corporate Dashboard Queries
|
||||
* Daily stats, KPIs, trends, top agents — used by /corporate routes
|
||||
*/
|
||||
const { pool, fmtDate } = require('./helpers');
|
||||
|
||||
// Fast daily stats for admin home (today and yesterday only)
|
||||
async function fetchDailyStats() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [brlUsdStats] = await conn.execute(`
|
||||
SELECT
|
||||
DATE(created_at) as dia,
|
||||
COUNT(*) as qtd,
|
||||
ROUND(SUM(amount_brl), 2) as total_brl,
|
||||
ROUND(SUM(amount_usd), 2) as total_usd
|
||||
FROM br_transaction_to_usa
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 1 DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY dia DESC
|
||||
`);
|
||||
|
||||
const [usdBrlStats] = await conn.execute(`
|
||||
SELECT
|
||||
DATE(created_at) as dia,
|
||||
COUNT(*) as qtd,
|
||||
ROUND(SUM(valor_sol), 2) as total_brl,
|
||||
ROUND(SUM(valor), 2) as total_usd
|
||||
FROM pagamento_br
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 1 DAY)
|
||||
AND cotacao IS NOT NULL AND cotacao > 0
|
||||
AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY dia DESC
|
||||
`);
|
||||
|
||||
const [usdUsdStats] = await conn.execute(`
|
||||
SELECT
|
||||
DATE(created_at) as dia,
|
||||
COUNT(*) as qtd,
|
||||
ROUND(SUM(valor), 2) as total_usd
|
||||
FROM pagamento_br
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 1 DAY)
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY dia DESC
|
||||
`);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
|
||||
const formatDay = (stats, targetDate) => {
|
||||
const row = stats.find(r => {
|
||||
const d = r.dia instanceof Date ? r.dia.toISOString().slice(0, 10) : String(r.dia).slice(0, 10);
|
||||
return d === targetDate;
|
||||
});
|
||||
return row ? {
|
||||
qtd: Number(row.qtd),
|
||||
total_brl: Number(row.total_brl) || 0,
|
||||
total_usd: Number(row.total_usd) || 0
|
||||
} : { qtd: 0, total_brl: 0, total_usd: 0 };
|
||||
};
|
||||
|
||||
return {
|
||||
brlUsd: {
|
||||
hoje: formatDay(brlUsdStats, today),
|
||||
ontem: formatDay(brlUsdStats, yesterday)
|
||||
},
|
||||
usdBrl: {
|
||||
hoje: formatDay(usdBrlStats, today),
|
||||
ontem: formatDay(usdBrlStats, yesterday)
|
||||
},
|
||||
usdUsd: {
|
||||
hoje: formatDay(usdUsdStats, today),
|
||||
ontem: formatDay(usdUsdStats, yesterday)
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// KPIs: hoje vs média 30 dias
|
||||
async function fetchKPIs() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [brlUsd] = await conn.execute(`
|
||||
SELECT
|
||||
SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) as hoje_qtd,
|
||||
SUM(CASE WHEN DATE(created_at) = CURDATE() THEN amount_usd ELSE 0 END) as hoje_usd,
|
||||
COUNT(*) / 30.0 as media_qtd,
|
||||
SUM(amount_usd) / 30.0 as media_usd
|
||||
FROM br_transaction_to_usa
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
`);
|
||||
|
||||
const [usdBrl] = await conn.execute(`
|
||||
SELECT
|
||||
SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) as hoje_qtd,
|
||||
SUM(CASE WHEN DATE(created_at) = CURDATE() THEN valor ELSE 0 END) as hoje_usd,
|
||||
COUNT(*) / 30.0 as media_qtd,
|
||||
SUM(valor) / 30.0 as media_usd
|
||||
FROM pagamento_br
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
AND cotacao IS NOT NULL AND cotacao > 0
|
||||
AND (pgto IS NULL OR pgto != 'balance')
|
||||
`);
|
||||
|
||||
const [usdUsd] = await conn.execute(`
|
||||
SELECT
|
||||
SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) as hoje_qtd,
|
||||
SUM(CASE WHEN DATE(created_at) = CURDATE() THEN valor ELSE 0 END) as hoje_usd,
|
||||
COUNT(*) / 30.0 as media_qtd,
|
||||
SUM(valor) / 30.0 as media_usd
|
||||
FROM pagamento_br
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
`);
|
||||
|
||||
const format = (row) => ({
|
||||
hoje_qtd: Number(row[0]?.hoje_qtd) || 0,
|
||||
hoje_usd: Number(row[0]?.hoje_usd) || 0,
|
||||
media_qtd: Math.round(Number(row[0]?.media_qtd) || 0),
|
||||
media_usd: Math.round(Number(row[0]?.media_usd) || 0)
|
||||
});
|
||||
|
||||
const brlUsdData = format(brlUsd);
|
||||
const usdBrlData = format(usdBrl);
|
||||
const usdUsdData = format(usdUsd);
|
||||
|
||||
return {
|
||||
brlUsd: brlUsdData,
|
||||
usdBrl: usdBrlData,
|
||||
usdUsd: usdUsdData,
|
||||
total: {
|
||||
hoje_qtd: brlUsdData.hoje_qtd + usdBrlData.hoje_qtd + usdUsdData.hoje_qtd,
|
||||
hoje_usd: brlUsdData.hoje_usd + usdBrlData.hoje_usd + usdUsdData.hoje_usd,
|
||||
media_qtd: brlUsdData.media_qtd + usdBrlData.media_qtd + usdUsdData.media_qtd,
|
||||
media_usd: brlUsdData.media_usd + usdBrlData.media_usd + usdUsdData.media_usd
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Tendência 30 dias
|
||||
async function fetchTrend30Days() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [brlUsd] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd, ROUND(SUM(amount_usd), 2) as vol_usd
|
||||
FROM br_transaction_to_usa
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`);
|
||||
|
||||
const [usdBrl] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd, ROUND(SUM(valor), 2) as vol_usd
|
||||
FROM pagamento_br
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
AND cotacao IS NOT NULL AND cotacao > 0
|
||||
AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`);
|
||||
|
||||
const [usdUsd] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd, ROUND(SUM(valor), 2) as vol_usd
|
||||
FROM pagamento_br
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`);
|
||||
|
||||
const formatRows = (rows) => rows.map(r => ({
|
||||
dia: fmtDate(r.dia),
|
||||
qtd: Number(r.qtd),
|
||||
vol_usd: Number(r.vol_usd)
|
||||
}));
|
||||
|
||||
return {
|
||||
brlUsd: formatRows(brlUsd),
|
||||
usdBrl: formatRows(usdBrl),
|
||||
usdUsd: formatRows(usdUsd)
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Tendência por período customizado
|
||||
async function fetchTrendByPeriod(dataInicio, dataFim) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [brlUsd] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(amount_usd), 2) as vol_usd,
|
||||
ROUND(SUM(amount_brl), 2) as vol_brl
|
||||
FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
const [usdBrl] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(valor), 2) as vol_usd,
|
||||
ROUND(SUM(valor_sol), 2) as vol_brl
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0
|
||||
AND (pgto IS NULL OR pgto != 'balance')
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
const [usdUsd] = await conn.execute(`
|
||||
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
|
||||
ROUND(SUM(valor), 2) as vol_usd
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
GROUP BY DATE(created_at) ORDER BY dia
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
const formatRows = (rows) => rows.map(r => ({
|
||||
dia: fmtDate(r.dia),
|
||||
qtd: Number(r.qtd),
|
||||
vol_usd: Number(r.vol_usd),
|
||||
vol_brl: Number(r.vol_brl) || 0
|
||||
}));
|
||||
|
||||
return {
|
||||
brlUsd: formatRows(brlUsd),
|
||||
usdBrl: formatRows(usdBrl),
|
||||
usdUsd: formatRows(usdUsd)
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// KPIs por período customizado
|
||||
async function fetchKPIsByPeriod(dataInicio, dataFim) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [brlUsd] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as qtd,
|
||||
ROUND(SUM(amount_usd), 2) as vol_usd,
|
||||
ROUND(SUM(amount_brl), 2) as vol_brl,
|
||||
ROUND(AVG(amount_usd), 2) as ticket_medio
|
||||
FROM br_transaction_to_usa
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
const [usdBrl] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as qtd,
|
||||
ROUND(SUM(valor), 2) as vol_usd,
|
||||
ROUND(SUM(valor_sol), 2) as vol_brl,
|
||||
ROUND(AVG(valor), 2) as ticket_medio
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND cotacao IS NOT NULL AND cotacao > 0
|
||||
AND (pgto IS NULL OR pgto != 'balance')
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
const [usdUsd] = await conn.execute(`
|
||||
SELECT
|
||||
COUNT(*) as qtd,
|
||||
ROUND(SUM(valor), 2) as vol_usd,
|
||||
ROUND(AVG(valor), 2) as ticket_medio
|
||||
FROM pagamento_br
|
||||
WHERE DATE(created_at) >= ? AND DATE(created_at) <= ?
|
||||
AND (cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')
|
||||
`, [dataInicio, dataFim]);
|
||||
|
||||
const format = (row) => ({
|
||||
qtd: Number(row[0]?.qtd) || 0,
|
||||
vol_usd: Number(row[0]?.vol_usd) || 0,
|
||||
vol_brl: Number(row[0]?.vol_brl) || 0,
|
||||
ticket_medio: Number(row[0]?.ticket_medio) || 0
|
||||
});
|
||||
|
||||
const brlUsdData = format(brlUsd);
|
||||
const usdBrlData = format(usdBrl);
|
||||
const usdUsdData = format(usdUsd);
|
||||
|
||||
return {
|
||||
brlUsd: brlUsdData,
|
||||
usdBrl: usdBrlData,
|
||||
usdUsd: usdUsdData,
|
||||
total: {
|
||||
qtd: brlUsdData.qtd + usdBrlData.qtd + usdUsdData.qtd,
|
||||
vol_usd: brlUsdData.vol_usd + usdBrlData.vol_usd + usdUsdData.vol_usd,
|
||||
vol_brl: brlUsdData.vol_brl + usdBrlData.vol_brl,
|
||||
ticket_medio: Math.round((brlUsdData.vol_usd + usdBrlData.vol_usd + usdUsdData.vol_usd) /
|
||||
(brlUsdData.qtd + usdBrlData.qtd + usdUsdData.qtd) || 0)
|
||||
},
|
||||
periodo: { inicio: dataInicio, fim: dataFim }
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Top 5 agentes por período
|
||||
async function fetchTopAgentes(dias = 30) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.execute(`
|
||||
SELECT
|
||||
agente_id,
|
||||
SUM(qtd) as total_qtd,
|
||||
ROUND(SUM(vol_usd), 2) as total_usd
|
||||
FROM (
|
||||
SELECT ac.agente_id, COUNT(*) as qtd, SUM(t.amount_usd) as vol_usd
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN ag_contas ac ON ac.conta_id = t.id_conta
|
||||
WHERE t.created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||
GROUP BY ac.agente_id
|
||||
UNION ALL
|
||||
SELECT ac.agente_id, COUNT(*) as qtd, SUM(p.valor) as vol_usd
|
||||
FROM pagamento_br p
|
||||
INNER JOIN ag_contas ac ON ac.conta_id = p.id_conta
|
||||
WHERE p.created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||
GROUP BY ac.agente_id
|
||||
) combined
|
||||
GROUP BY agente_id
|
||||
ORDER BY total_usd DESC
|
||||
LIMIT 5
|
||||
`, [dias, dias]);
|
||||
|
||||
return rows.map((r, i) => ({
|
||||
rank: i + 1,
|
||||
agente_id: r.agente_id,
|
||||
qtd: Number(r.total_qtd),
|
||||
vol_usd: Number(r.total_usd)
|
||||
}));
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchDailyStats,
|
||||
fetchKPIs,
|
||||
fetchTrend30Days,
|
||||
fetchTrendByPeriod,
|
||||
fetchKPIsByPeriod,
|
||||
fetchTopAgentes
|
||||
};
|
||||
112
src/queries/helpers.js
Normal file
112
src/queries/helpers.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Shared SQL fragments, revenue formulas, and utility functions
|
||||
* Used across all query modules to eliminate duplication
|
||||
*/
|
||||
const pool = require('../db-rds');
|
||||
|
||||
// --- Utility Functions ---
|
||||
|
||||
function parseDate(d) {
|
||||
try {
|
||||
if (!d) return null;
|
||||
const dt = d instanceof Date ? d : new Date(d);
|
||||
return isNaN(dt.getTime()) ? null : dt.toISOString().slice(0, 16).replace('T', ' ');
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
return d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10);
|
||||
}
|
||||
|
||||
function fmtDateTime(d) {
|
||||
try {
|
||||
const dt = d instanceof Date ? d : new Date(d);
|
||||
return dt.toISOString().slice(0, 16).replace('T', ' ');
|
||||
} catch (e) { return String(d); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate previous period dates for comparison
|
||||
*/
|
||||
function calcPrevPeriod(dataInicio, dataFim) {
|
||||
const start = new Date(dataInicio);
|
||||
const end = new Date(dataFim);
|
||||
const periodDays = Math.round((end - start) / (1000 * 60 * 60 * 24)) + 1;
|
||||
const prevEnd = new Date(start);
|
||||
prevEnd.setDate(prevEnd.getDate() - 1);
|
||||
const prevStart = new Date(prevEnd);
|
||||
prevStart.setDate(prevStart.getDate() - periodDays + 1);
|
||||
return {
|
||||
prevStartStr: prevStart.toISOString().slice(0, 10),
|
||||
prevEndStr: prevEnd.toISOString().slice(0, 10),
|
||||
periodDays
|
||||
};
|
||||
}
|
||||
|
||||
// --- Reusable SQL Fragments ---
|
||||
|
||||
// BRL→USD spread revenue formula (per-row)
|
||||
const SQL_BRLUSD_SPREAD_REVENUE = `(exchange_rate - ptax) / exchange_rate * amount_usd`;
|
||||
|
||||
// BRL→USD average spread percentage
|
||||
const SQL_BRLUSD_SPREAD_PCT = `(exchange_rate - ptax) / exchange_rate * 100`;
|
||||
|
||||
// USD→BRL spread revenue formula (per-row)
|
||||
const SQL_USDBRL_SPREAD_REVENUE = `(ptax - cotacao) / ptax * valor`;
|
||||
|
||||
// USD→BRL average spread percentage
|
||||
const SQL_USDBRL_SPREAD_PCT = `CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END`;
|
||||
|
||||
// USD→BRL filter: real currency exchange (not balance)
|
||||
const SQL_USDBRL_FILTER = `cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')`;
|
||||
|
||||
// USD→USD filter: balance or no cotacao
|
||||
const SQL_USDUSD_FILTER = `(cotacao IS NULL OR cotacao = 0 OR pgto = 'balance')`;
|
||||
|
||||
// BR→US Revenue formula (real P&L per transaction)
|
||||
const SQL_BRUS_REVENUE = `(
|
||||
(
|
||||
ROUND((t.amount_brl - IF(pm.provider IN ('ouribank','bs2'), 0, t.fee)) / t.ptax, 2)
|
||||
- COALESCE(t.pfee, 0)
|
||||
) - (
|
||||
t.amount_usd + COALESCE(t.bonus_valor, 0) - COALESCE(t.taxa_cr, 0)
|
||||
)
|
||||
)`;
|
||||
|
||||
// BR→US valid provider filter
|
||||
const SQL_BRUS_PROVIDER_FILTER = `pm.provider IN ('dlocal','bexs','braza','bs2','ouribank','msb')`;
|
||||
|
||||
// BR→US valid transaction status filter
|
||||
const SQL_BRUS_STATUS_FILTER = `(t.status IN ('boleto_pago','finalizado') OR t.date_sent_usa <> '0000-00-00 00:00:00')`;
|
||||
|
||||
// Standard completed status for br_transaction_to_usa
|
||||
const SQL_COMPLETED_STATUSES = `('boleto_pago','finalizado')`;
|
||||
|
||||
// Format trend rows (common across all query modules)
|
||||
function fmtTrendRows(rows) {
|
||||
return rows.map(r => ({
|
||||
dia: fmtDate(r.dia),
|
||||
qtd: Number(r.qtd),
|
||||
vol_usd: Number(r.vol_usd),
|
||||
avg_spread: Number(r.avg_spread) || 0
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
pool,
|
||||
parseDate,
|
||||
fmtDate,
|
||||
fmtDateTime,
|
||||
calcPrevPeriod,
|
||||
fmtTrendRows,
|
||||
SQL_BRLUSD_SPREAD_REVENUE,
|
||||
SQL_BRLUSD_SPREAD_PCT,
|
||||
SQL_USDBRL_SPREAD_REVENUE,
|
||||
SQL_USDBRL_SPREAD_PCT,
|
||||
SQL_USDBRL_FILTER,
|
||||
SQL_USDUSD_FILTER,
|
||||
SQL_BRUS_REVENUE,
|
||||
SQL_BRUS_PROVIDER_FILTER,
|
||||
SQL_BRUS_STATUS_FILTER,
|
||||
SQL_COMPLETED_STATUSES
|
||||
};
|
||||
142
src/queries/payin.queries.js
Normal file
142
src/queries/payin.queries.js
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* PayIn Queries — BRL→USD transactions (br_transaction_to_usa)
|
||||
* Agent-level and admin-level transaction fetching
|
||||
*/
|
||||
const { pool, parseDate } = require('./helpers');
|
||||
|
||||
async function fetchTransacoes(agenteId) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rowsBrlUsd] = await conn.execute(`
|
||||
SELECT DISTINCT
|
||||
c.nome AS cliente,
|
||||
t.created_at AS data_operacao,
|
||||
t.amount_brl AS valor_reais,
|
||||
t.amount_usd AS valor_dolar,
|
||||
t.iof AS iof_pct,
|
||||
ROUND(t.iof / 100 * t.amount_usd * t.exchange_rate, 2) AS iof_valor_rs,
|
||||
ROUND(t.ptax, 4) AS taxa_ptax,
|
||||
ROUND(t.exchange_rate, 4) AS taxa_cobrada,
|
||||
ROUND(t.exchange_rate - t.ptax, 4) AS spread_bruto,
|
||||
ROUND((t.exchange_rate - t.ptax) / t.exchange_rate * 100, 2) AS spread_pct,
|
||||
t.status
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN ag_contas ac ON ac.conta_id = t.id_conta AND ac.agente_id = ?
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
ORDER BY t.created_at
|
||||
`, [agenteId]);
|
||||
|
||||
const [rowsUsdBrl] = await conn.execute(`
|
||||
SELECT DISTINCT
|
||||
c.nome AS cliente,
|
||||
p.created_at AS data_operacao,
|
||||
ROUND(p.valor * p.cotacao, 2) AS valor_reais,
|
||||
p.valor AS valor_dolar,
|
||||
0 AS iof_pct,
|
||||
0 AS iof_valor_rs,
|
||||
ROUND(p.ptax, 4) AS taxa_ptax,
|
||||
ROUND(p.cotacao, 4) AS taxa_cobrada,
|
||||
ROUND(p.ptax - p.cotacao, 4) AS spread_bruto,
|
||||
CASE WHEN p.cotacao > 0 THEN ROUND((p.ptax - p.cotacao) / p.ptax * 100, 2) ELSE 0 END AS spread_pct,
|
||||
p.pgto AS status
|
||||
FROM pagamento_br p
|
||||
INNER JOIN conta c ON c.id_conta = p.id_conta
|
||||
INNER JOIN ag_contas ac ON ac.conta_id = p.id_conta AND ac.agente_id = ?
|
||||
ORDER BY p.created_at
|
||||
`, [agenteId]);
|
||||
|
||||
return { rowsBrlUsd, rowsUsdBrl };
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
function serialize(rowsBrlUsd, rowsUsdBrl) {
|
||||
const dataBrlUsd = rowsBrlUsd.map(r => ({
|
||||
fluxo: 'BRL → USD',
|
||||
cliente: r.cliente,
|
||||
data_operacao: parseDate(r.data_operacao),
|
||||
data_sort: parseDate(r.data_operacao) || '',
|
||||
valor_reais: Number(r.valor_reais),
|
||||
valor_dolar: Number(r.valor_dolar),
|
||||
iof_pct: Number(r.iof_pct),
|
||||
iof_valor_rs: Number(r.iof_valor_rs),
|
||||
taxa_ptax: Number(r.taxa_ptax),
|
||||
taxa_cobrada: Number(r.taxa_cobrada),
|
||||
spread_bruto: Number(r.spread_bruto),
|
||||
spread_pct: Number(r.spread_pct),
|
||||
status: r.status,
|
||||
}));
|
||||
|
||||
const dataUsdBrl = rowsUsdBrl.map(r => ({
|
||||
fluxo: 'USD → BRL',
|
||||
cliente: r.cliente,
|
||||
data_operacao: parseDate(r.data_operacao),
|
||||
data_sort: parseDate(r.data_operacao) || '',
|
||||
valor_reais: Number(r.valor_reais),
|
||||
valor_dolar: Number(r.valor_dolar),
|
||||
iof_pct: Number(r.iof_pct),
|
||||
iof_valor_rs: Number(r.iof_valor_rs),
|
||||
taxa_ptax: Number(r.taxa_ptax),
|
||||
taxa_cobrada: Number(r.taxa_cobrada),
|
||||
spread_bruto: Number(r.spread_bruto),
|
||||
spread_pct: Number(r.spread_pct),
|
||||
status: r.status,
|
||||
}));
|
||||
|
||||
return [...dataBrlUsd, ...dataUsdBrl].sort((a, b) => a.data_sort.localeCompare(b.data_sort));
|
||||
}
|
||||
|
||||
// Fetch ALL transactions (for admin) - with date filter for performance
|
||||
async function fetchAllTransacoes(diasAtras = 90) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rowsBrlUsd] = await conn.execute(`
|
||||
SELECT
|
||||
c.nome AS cliente,
|
||||
t.created_at AS data_operacao,
|
||||
t.amount_brl AS valor_reais,
|
||||
t.amount_usd AS valor_dolar,
|
||||
t.iof AS iof_pct,
|
||||
ROUND(t.iof / 100 * t.amount_usd * t.exchange_rate, 2) AS iof_valor_rs,
|
||||
ROUND(t.ptax, 4) AS taxa_ptax,
|
||||
ROUND(t.exchange_rate, 4) AS taxa_cobrada,
|
||||
ROUND(t.exchange_rate - t.ptax, 4) AS spread_bruto,
|
||||
ROUND((t.exchange_rate - t.ptax) / t.exchange_rate * 100, 2) AS spread_pct,
|
||||
t.status
|
||||
FROM br_transaction_to_usa t
|
||||
INNER JOIN conta c ON c.id_conta = t.id_conta
|
||||
WHERE t.created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||
ORDER BY t.created_at DESC
|
||||
`, [diasAtras]);
|
||||
|
||||
const [rowsUsdBrl] = await conn.execute(`
|
||||
SELECT
|
||||
c.nome AS cliente,
|
||||
p.created_at AS data_operacao,
|
||||
ROUND(p.valor * p.cotacao, 2) AS valor_reais,
|
||||
p.valor AS valor_dolar,
|
||||
0 AS iof_pct,
|
||||
0 AS iof_valor_rs,
|
||||
ROUND(p.ptax, 4) AS taxa_ptax,
|
||||
ROUND(p.cotacao, 4) AS taxa_cobrada,
|
||||
ROUND(p.ptax - p.cotacao, 4) AS spread_bruto,
|
||||
CASE WHEN p.cotacao > 0 THEN ROUND((p.ptax - p.cotacao) / p.ptax * 100, 2) ELSE 0 END AS spread_pct,
|
||||
p.pgto AS status
|
||||
FROM pagamento_br p
|
||||
INNER JOIN conta c ON c.id_conta = p.id_conta
|
||||
WHERE p.created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||
ORDER BY p.created_at DESC
|
||||
`, [diasAtras]);
|
||||
|
||||
return { rowsBrlUsd, rowsUsdBrl };
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchTransacoes,
|
||||
serialize,
|
||||
fetchAllTransacoes
|
||||
};
|
||||
11
src/queries/payout.queries.js
Normal file
11
src/queries/payout.queries.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* PayOut Queries — USD→BRL payments (pagamento_br)
|
||||
* Currently the payout-specific queries are embedded in payin.queries.js and corporate.queries.js
|
||||
* This module serves as a namespace for future payout-specific analytics
|
||||
*/
|
||||
|
||||
// Currently payout queries are co-located with their paired payin queries
|
||||
// (fetchTransacoes returns both BRL→USD and USD→BRL, etc.)
|
||||
// This module is a placeholder for payout-only queries as they emerge.
|
||||
|
||||
module.exports = {};
|
||||
198
src/queries/provider.queries.js
Normal file
198
src/queries/provider.queries.js
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Provider Performance Queries
|
||||
* Success rates, volumes, settlement analytics per payment provider
|
||||
*/
|
||||
const { pool, fmtDate } = require('./helpers');
|
||||
|
||||
// Provider performance: success rate, volume, avg ticket, spread, settlement per provider
|
||||
async function fetchProviderPerformance(start, end) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
// BRL→USD providers (br_payment_methods)
|
||||
const [payinProviders] = await conn.execute(`
|
||||
SELECT
|
||||
COALESCE(pm.provider, 'N/A') as provider,
|
||||
'BRL→USD' as flow,
|
||||
COUNT(*) as total_tx,
|
||||
SUM(CASE WHEN t.status IN ('boleto_pago','finalizado') OR t.date_sent_usa <> '0000-00-00 00:00:00' THEN 1 ELSE 0 END) as success_tx,
|
||||
ROUND(COALESCE(SUM(t.amount_usd), 0), 2) as vol_usd,
|
||||
ROUND(COALESCE(AVG(t.amount_usd), 0), 2) as avg_ticket,
|
||||
ROUND(COALESCE(AVG((t.exchange_rate - t.ptax) / t.exchange_rate * 100), 0), 2) as avg_spread_pct,
|
||||
ROUND(COALESCE(AVG(
|
||||
CASE WHEN t.date_sent_usa <> '0000-00-00 00:00:00' AND t.created_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(HOUR, t.created_at, t.date_sent_usa) END
|
||||
), 0), 1) as avg_settlement_hours
|
||||
FROM br_transaction_to_usa t
|
||||
LEFT JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
GROUP BY pm.provider
|
||||
ORDER BY vol_usd DESC
|
||||
`, [start, end]);
|
||||
|
||||
// USD→BRL providers (tipo_envio)
|
||||
const [payoutProviders] = await conn.execute(`
|
||||
SELECT
|
||||
COALESCE(p.tipo_envio, 'N/A') as provider,
|
||||
'USD→BRL' as flow,
|
||||
COUNT(*) as total_tx,
|
||||
SUM(CASE WHEN p.data_cp IS NOT NULL AND p.data_cp <> '0000-00-00' THEN 1 ELSE 0 END) as success_tx,
|
||||
ROUND(COALESCE(SUM(p.valor), 0), 2) as vol_usd,
|
||||
ROUND(COALESCE(AVG(p.valor), 0), 2) as avg_ticket,
|
||||
ROUND(COALESCE(AVG(CASE WHEN p.cotacao > 0 THEN (p.ptax - p.cotacao) / p.ptax * 100 ELSE 0 END), 0), 2) as avg_spread_pct,
|
||||
ROUND(COALESCE(AVG(
|
||||
CASE WHEN p.data_cp IS NOT NULL AND p.data_cp <> '0000-00-00'
|
||||
THEN TIMESTAMPDIFF(HOUR, p.created_at, p.data_cp) END
|
||||
), 0), 1) as avg_settlement_hours
|
||||
FROM pagamento_br p
|
||||
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
||||
AND p.cotacao IS NOT NULL AND p.cotacao > 0
|
||||
AND (p.pgto IS NULL OR p.pgto != 'balance')
|
||||
GROUP BY p.tipo_envio
|
||||
ORDER BY vol_usd DESC
|
||||
`, [start, end]);
|
||||
|
||||
const allProviders = [...payinProviders, ...payoutProviders].map(r => ({
|
||||
provider: r.provider,
|
||||
flow: r.flow,
|
||||
total_tx: Number(r.total_tx) || 0,
|
||||
success_tx: Number(r.success_tx) || 0,
|
||||
success_rate: Number(r.total_tx) > 0 ? Math.round(Number(r.success_tx) / Number(r.total_tx) * 10000) / 100 : 0,
|
||||
vol_usd: Number(r.vol_usd) || 0,
|
||||
avg_ticket: Number(r.avg_ticket) || 0,
|
||||
avg_spread_pct: Number(r.avg_spread_pct) || 0,
|
||||
avg_settlement_hours: Number(r.avg_settlement_hours) || 0
|
||||
}));
|
||||
|
||||
// Summary KPIs
|
||||
const totalProviders = new Set(allProviders.map(p => p.provider)).size;
|
||||
const totalTx = allProviders.reduce((s, p) => s + p.total_tx, 0);
|
||||
const totalSuccess = allProviders.reduce((s, p) => s + p.success_tx, 0);
|
||||
const totalVol = allProviders.reduce((s, p) => s + p.vol_usd, 0);
|
||||
const avgSettlement = allProviders.length > 0
|
||||
? Math.round(allProviders.reduce((s, p) => s + p.avg_settlement_hours, 0) / allProviders.length * 10) / 10
|
||||
: 0;
|
||||
|
||||
return {
|
||||
providers: allProviders,
|
||||
summary: {
|
||||
total_providers: totalProviders,
|
||||
overall_success_rate: totalTx > 0 ? Math.round(totalSuccess / totalTx * 10000) / 100 : 0,
|
||||
total_volume: totalVol,
|
||||
avg_settlement_hours: avgSettlement
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Failed transactions breakdown by status, provider, date
|
||||
async function fetchFailedTransactions(start, end) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
// BRL→USD failed
|
||||
const [payinFailed] = await conn.execute(`
|
||||
SELECT
|
||||
COALESCE(pm.provider, 'N/A') as provider,
|
||||
'BRL→USD' as flow,
|
||||
t.status,
|
||||
COUNT(*) as count,
|
||||
ROUND(COALESCE(SUM(t.amount_usd), 0), 2) as vol_usd,
|
||||
DATE(t.created_at) as date
|
||||
FROM br_transaction_to_usa t
|
||||
LEFT JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
AND t.status NOT IN ('boleto_pago','finalizado')
|
||||
AND (t.date_sent_usa IS NULL OR t.date_sent_usa = '0000-00-00 00:00:00')
|
||||
GROUP BY pm.provider, t.status, DATE(t.created_at)
|
||||
ORDER BY count DESC
|
||||
`, [start, end]);
|
||||
|
||||
// USD→BRL failed
|
||||
const [payoutFailed] = await conn.execute(`
|
||||
SELECT
|
||||
COALESCE(p.tipo_envio, 'N/A') as provider,
|
||||
'USD→BRL' as flow,
|
||||
COALESCE(p.pgto, 'pending') as status,
|
||||
COUNT(*) as count,
|
||||
ROUND(COALESCE(SUM(p.valor), 0), 2) as vol_usd,
|
||||
DATE(p.created_at) as date
|
||||
FROM pagamento_br p
|
||||
WHERE DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
|
||||
AND (p.data_cp IS NULL OR p.data_cp = '0000-00-00')
|
||||
AND p.cotacao IS NOT NULL AND p.cotacao > 0
|
||||
AND (p.pgto IS NULL OR p.pgto != 'balance')
|
||||
GROUP BY p.tipo_envio, p.pgto, DATE(p.created_at)
|
||||
ORDER BY count DESC
|
||||
`, [start, end]);
|
||||
|
||||
const allFailed = [...payinFailed, ...payoutFailed].map(r => ({
|
||||
provider: r.provider,
|
||||
flow: r.flow,
|
||||
status: r.status,
|
||||
count: Number(r.count),
|
||||
vol_usd: Number(r.vol_usd),
|
||||
date: fmtDate(r.date)
|
||||
}));
|
||||
|
||||
// Summary by provider
|
||||
const byProvider = {};
|
||||
allFailed.forEach(r => {
|
||||
if (!byProvider[r.provider]) byProvider[r.provider] = { provider: r.provider, count: 0, vol_usd: 0 };
|
||||
byProvider[r.provider].count += r.count;
|
||||
byProvider[r.provider].vol_usd += r.vol_usd;
|
||||
});
|
||||
|
||||
// Summary by status
|
||||
const byStatus = {};
|
||||
allFailed.forEach(r => {
|
||||
if (!byStatus[r.status]) byStatus[r.status] = { status: r.status, count: 0, vol_usd: 0 };
|
||||
byStatus[r.status].count += r.count;
|
||||
byStatus[r.status].vol_usd += r.vol_usd;
|
||||
});
|
||||
|
||||
return {
|
||||
details: allFailed,
|
||||
byProvider: Object.values(byProvider).sort((a, b) => b.count - a.count),
|
||||
byStatus: Object.values(byStatus).sort((a, b) => b.count - a.count),
|
||||
total_failed: allFailed.reduce((s, r) => s + r.count, 0),
|
||||
total_vol_failed: Math.round(allFailed.reduce((s, r) => s + r.vol_usd, 0) * 100) / 100
|
||||
};
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Provider trend: daily volume/count per provider
|
||||
async function fetchProviderTrend(start, end) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [payinTrend] = await conn.execute(`
|
||||
SELECT
|
||||
DATE(t.created_at) as dia,
|
||||
COALESCE(pm.provider, 'N/A') as provider,
|
||||
COUNT(*) as qtd,
|
||||
ROUND(SUM(t.amount_usd), 2) as vol_usd
|
||||
FROM br_transaction_to_usa t
|
||||
LEFT JOIN br_payment_methods pm ON t.payment_method_id = pm.id
|
||||
WHERE DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
|
||||
GROUP BY DATE(t.created_at), pm.provider
|
||||
ORDER BY dia, provider
|
||||
`, [start, end]);
|
||||
|
||||
return payinTrend.map(r => ({
|
||||
dia: fmtDate(r.dia),
|
||||
provider: r.provider,
|
||||
qtd: Number(r.qtd),
|
||||
vol_usd: Number(r.vol_usd)
|
||||
}));
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchProviderPerformance,
|
||||
fetchFailedTransactions,
|
||||
fetchProviderTrend
|
||||
};
|
||||
126
src/services/churn-predictor.js
Normal file
126
src/services/churn-predictor.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Churn Prediction — Weighted RFM Model
|
||||
* Recency 30%, Frequency 25%, Monetary 20%, Velocity 15%, Engagement 10%
|
||||
*/
|
||||
|
||||
const WEIGHTS = {
|
||||
recency: 0.30,
|
||||
frequency: 0.25,
|
||||
monetary: 0.20,
|
||||
velocity: 0.15,
|
||||
engagement: 0.10
|
||||
};
|
||||
|
||||
/**
|
||||
* Score a single dimension 0-100 (higher = healthier / lower risk)
|
||||
*/
|
||||
function scoreRecency(daysInactive) {
|
||||
if (daysInactive === null || daysInactive === undefined) return 0;
|
||||
if (daysInactive <= 3) return 100;
|
||||
if (daysInactive <= 7) return 90;
|
||||
if (daysInactive <= 14) return 75;
|
||||
if (daysInactive <= 30) return 55;
|
||||
if (daysInactive <= 60) return 35;
|
||||
if (daysInactive <= 90) return 15;
|
||||
return 5;
|
||||
}
|
||||
|
||||
function scoreFrequency(opsPerMonth) {
|
||||
if (!opsPerMonth || opsPerMonth <= 0) return 0;
|
||||
if (opsPerMonth >= 20) return 100;
|
||||
if (opsPerMonth >= 10) return 85;
|
||||
if (opsPerMonth >= 5) return 70;
|
||||
if (opsPerMonth >= 2) return 50;
|
||||
if (opsPerMonth >= 1) return 30;
|
||||
return 10;
|
||||
}
|
||||
|
||||
function scoreMonetary(avgMonthlyVol) {
|
||||
if (!avgMonthlyVol || avgMonthlyVol <= 0) return 0;
|
||||
if (avgMonthlyVol >= 100000) return 100;
|
||||
if (avgMonthlyVol >= 50000) return 85;
|
||||
if (avgMonthlyVol >= 20000) return 70;
|
||||
if (avgMonthlyVol >= 5000) return 50;
|
||||
if (avgMonthlyVol >= 1000) return 30;
|
||||
return 10;
|
||||
}
|
||||
|
||||
function scoreVelocity(currPeriodOps, prevPeriodOps) {
|
||||
if (!prevPeriodOps || prevPeriodOps <= 0) {
|
||||
return currPeriodOps > 0 ? 60 : 0; // New client or dormant
|
||||
}
|
||||
const ratio = currPeriodOps / prevPeriodOps;
|
||||
if (ratio >= 1.5) return 100; // Strong growth
|
||||
if (ratio >= 1.1) return 85; // Growth
|
||||
if (ratio >= 0.9) return 70; // Stable
|
||||
if (ratio >= 0.7) return 40; // Declining
|
||||
if (ratio >= 0.5) return 20; // Significant decline
|
||||
return 5; // Severe decline
|
||||
}
|
||||
|
||||
function scoreEngagement(monthsActive, productCount) {
|
||||
// Product count: 1 = single flow, 2 = both BRL→USD and USD→BRL, 3 = includes checkout
|
||||
const tenurePts = Math.min(monthsActive || 0, 24) / 24 * 50;
|
||||
const productPts = Math.min(productCount || 1, 3) / 3 * 50;
|
||||
return Math.round(tenurePts + productPts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict churn risk for a client
|
||||
* @param {Object} clientData - Client profile data
|
||||
* @param {number} clientData.days_inactive - Days since last transaction
|
||||
* @param {number} clientData.avg_monthly_ops - Average operations per month
|
||||
* @param {number} clientData.avg_monthly_vol - Average monthly volume in USD
|
||||
* @param {number} clientData.months_active - Total months with activity
|
||||
* @param {number} [clientData.curr_ops] - Current period operations
|
||||
* @param {number} [clientData.prev_ops] - Previous period operations
|
||||
* @param {number} [clientData.product_count] - Number of products used (1-3)
|
||||
* @returns {{ score: number, risk: string, factors: Object[] }}
|
||||
*/
|
||||
function predictChurnRisk(clientData) {
|
||||
const scores = {
|
||||
recency: scoreRecency(clientData.days_inactive),
|
||||
frequency: scoreFrequency(clientData.avg_monthly_ops),
|
||||
monetary: scoreMonetary(clientData.avg_monthly_vol),
|
||||
velocity: scoreVelocity(clientData.curr_ops || 0, clientData.prev_ops || 0),
|
||||
engagement: scoreEngagement(clientData.months_active, clientData.product_count || 1)
|
||||
};
|
||||
|
||||
// Weighted score (0-100, higher = healthier)
|
||||
const healthScore = Math.round(
|
||||
scores.recency * WEIGHTS.recency +
|
||||
scores.frequency * WEIGHTS.frequency +
|
||||
scores.monetary * WEIGHTS.monetary +
|
||||
scores.velocity * WEIGHTS.velocity +
|
||||
scores.engagement * WEIGHTS.engagement
|
||||
);
|
||||
|
||||
// Invert to churn risk (0-100, higher = more likely to churn)
|
||||
const churnScore = 100 - healthScore;
|
||||
|
||||
let risk;
|
||||
if (churnScore >= 75) risk = 'critical';
|
||||
else if (churnScore >= 50) risk = 'high';
|
||||
else if (churnScore >= 25) risk = 'medium';
|
||||
else risk = 'low';
|
||||
|
||||
// Identify key risk factors
|
||||
const factors = Object.entries(scores)
|
||||
.map(([name, score]) => ({
|
||||
name,
|
||||
score,
|
||||
weight: WEIGHTS[name],
|
||||
contribution: Math.round((100 - score) * WEIGHTS[name]),
|
||||
status: score >= 70 ? 'good' : score >= 40 ? 'warning' : 'critical'
|
||||
}))
|
||||
.sort((a, b) => b.contribution - a.contribution);
|
||||
|
||||
return {
|
||||
score: churnScore,
|
||||
health_score: healthScore,
|
||||
risk,
|
||||
factors
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { predictChurnRisk };
|
||||
103
src/services/forecast.js
Normal file
103
src/services/forecast.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Volume Forecasting — Exponential Smoothing with Confidence Bands
|
||||
* Pure JS, no external dependencies
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simple Exponential Smoothing (SES) forecast
|
||||
* @param {number[]} data - Historical time series values
|
||||
* @param {number} periodsAhead - Number of periods to forecast
|
||||
* @param {number} alpha - Smoothing factor (0-1), default 0.3
|
||||
* @returns {{ forecast: number[], confidence: { upper: number[], lower: number[] }, fitted: number[] }}
|
||||
*/
|
||||
function forecast(data, periodsAhead = 7, alpha = 0.3) {
|
||||
if (!data || data.length === 0) {
|
||||
return { forecast: [], confidence: { upper: [], lower: [] }, fitted: [] };
|
||||
}
|
||||
|
||||
// Initialize with first value
|
||||
const n = data.length;
|
||||
const fitted = new Array(n);
|
||||
fitted[0] = data[0];
|
||||
|
||||
// Fit the model
|
||||
for (let i = 1; i < n; i++) {
|
||||
fitted[i] = alpha * data[i] + (1 - alpha) * fitted[i - 1];
|
||||
}
|
||||
|
||||
// Calculate residuals for confidence bands
|
||||
const residuals = data.map((v, i) => v - fitted[i]);
|
||||
const mse = residuals.reduce((s, r) => s + r * r, 0) / n;
|
||||
const rmse = Math.sqrt(mse);
|
||||
|
||||
// Forecast
|
||||
const lastFitted = fitted[n - 1];
|
||||
const forecastValues = [];
|
||||
const upper = [];
|
||||
const lower = [];
|
||||
|
||||
// Also apply Holt's trend if data shows clear trend
|
||||
const trendWindow = Math.min(7, Math.floor(n / 2));
|
||||
let trend = 0;
|
||||
if (n >= 4) {
|
||||
const recentAvg = data.slice(-trendWindow).reduce((s, v) => s + v, 0) / trendWindow;
|
||||
const olderAvg = data.slice(-trendWindow * 2, -trendWindow).reduce((s, v) => s + v, 0) / Math.min(trendWindow, data.slice(-trendWindow * 2, -trendWindow).length || 1);
|
||||
if (olderAvg > 0) {
|
||||
trend = (recentAvg - olderAvg) / trendWindow;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 1; i <= periodsAhead; i++) {
|
||||
const predicted = lastFitted + trend * i;
|
||||
const confidenceWidth = rmse * 1.96 * Math.sqrt(i); // 95% confidence
|
||||
forecastValues.push(Math.max(0, Math.round(predicted * 100) / 100));
|
||||
upper.push(Math.max(0, Math.round((predicted + confidenceWidth) * 100) / 100));
|
||||
lower.push(Math.max(0, Math.round((predicted - confidenceWidth) * 100) / 100));
|
||||
}
|
||||
|
||||
return {
|
||||
forecast: forecastValues,
|
||||
confidence: { upper, lower },
|
||||
fitted: fitted.map(v => Math.round(v * 100) / 100)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare time series from daily trend data and forecast ahead
|
||||
* @param {Object[]} trendData - Array of { dia, vol_usd } or { dia, qtd }
|
||||
* @param {string} metric - 'vol_usd' or 'qtd'
|
||||
* @param {number} daysAhead - Days to forecast
|
||||
* @returns {{ historical: Object[], predicted: Object[], confidence_upper: Object[], confidence_lower: Object[] }}
|
||||
*/
|
||||
function forecastFromTrend(trendData, metric = 'vol_usd', daysAhead = 30) {
|
||||
if (!trendData || trendData.length < 3) {
|
||||
return { historical: trendData || [], predicted: [], confidence_upper: [], confidence_lower: [] };
|
||||
}
|
||||
|
||||
const values = trendData.map(d => Number(d[metric]) || 0);
|
||||
const result = forecast(values, daysAhead);
|
||||
|
||||
// Generate future dates
|
||||
const lastDate = new Date(trendData[trendData.length - 1].dia);
|
||||
const predicted = [];
|
||||
const confUpper = [];
|
||||
const confLower = [];
|
||||
|
||||
for (let i = 0; i < daysAhead; i++) {
|
||||
const d = new Date(lastDate);
|
||||
d.setDate(d.getDate() + i + 1);
|
||||
const dia = d.toISOString().slice(0, 10);
|
||||
predicted.push({ dia, [metric]: result.forecast[i] });
|
||||
confUpper.push({ dia, [metric]: result.confidence.upper[i] });
|
||||
confLower.push({ dia, [metric]: result.confidence.lower[i] });
|
||||
}
|
||||
|
||||
return {
|
||||
historical: trendData,
|
||||
predicted,
|
||||
confidence_upper: confUpper,
|
||||
confidence_lower: confLower
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { forecast, forecastFromTrend };
|
||||
@@ -229,6 +229,46 @@ const headerCSS = `
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
/* Alert Bell */
|
||||
.alert-bell {
|
||||
position: relative; cursor: pointer; color: white;
|
||||
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255,255,255,0.1); border-radius: 50%; transition: all 0.2s;
|
||||
}
|
||||
.alert-bell:hover { background: rgba(255,255,255,0.2); }
|
||||
.alert-badge {
|
||||
position: absolute; top: -2px; right: -2px; background: #D93025; color: white;
|
||||
font-size: 10px; font-weight: 700; min-width: 18px; height: 18px; line-height: 18px;
|
||||
text-align: center; border-radius: 9px; padding: 0 4px;
|
||||
}
|
||||
.alert-dropdown {
|
||||
position: absolute; top: 60px; right: 40px; width: 360px; max-height: 400px;
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.2); z-index: 1000; overflow: hidden;
|
||||
}
|
||||
.alert-dropdown-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||
font-size: 13px; color: var(--text);
|
||||
}
|
||||
.alert-dropdown-close { cursor: pointer; font-size: 18px; color: var(--text-muted); }
|
||||
.alert-dropdown-body {
|
||||
max-height: 340px; overflow-y: auto; padding: 8px;
|
||||
}
|
||||
.alert-item {
|
||||
padding: 10px 12px; border-radius: 8px; margin-bottom: 4px;
|
||||
font-size: 12px; line-height: 1.4; cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.alert-item:hover { background: var(--bg); }
|
||||
.alert-item .alert-severity {
|
||||
display: inline-block; padding: 1px 6px; border-radius: 4px;
|
||||
font-size: 10px; font-weight: 700; margin-right: 6px;
|
||||
}
|
||||
.alert-item .alert-severity.P0 { background: var(--red-bg); color: var(--red); }
|
||||
.alert-item .alert-severity.P1 { background: var(--orange-bg); color: var(--orange); }
|
||||
.alert-item .alert-severity.P2 { background: var(--blue-bg); color: var(--blue); }
|
||||
.alert-item .alert-time { color: var(--text-muted); font-size: 10px; display: block; margin-top: 4px; }
|
||||
|
||||
/* Theme Toggle */
|
||||
.btn-theme-toggle {
|
||||
background: rgba(255,255,255,0.15);
|
||||
@@ -361,12 +401,13 @@ function buildHeader(options = {}) {
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
|
||||
// Admin navigation: Corporate Dashboard + BI + Users
|
||||
// Admin navigation: Corporate Dashboard + BI + Clients + Providers + Users
|
||||
const adminNav = `
|
||||
<nav class="header-nav">
|
||||
<a href="/corporate" class="${activePage === 'dashboard' ? 'active' : ''}">Corporate</a>
|
||||
<a href="/admin/bi" class="${activePage === 'bi' ? 'active' : ''}">BI Executive</a>
|
||||
<a href="/admin/cliente" class="${activePage === 'cliente' ? 'active' : ''}">Clientes</a>
|
||||
<a href="/admin/providers" class="${activePage === 'providers' ? 'active' : ''}">Providers</a>
|
||||
<a href="/admin" class="${activePage === 'users' ? 'active' : ''}">Usuarios</a>
|
||||
</nav>
|
||||
`;
|
||||
@@ -412,6 +453,21 @@ function buildHeader(options = {}) {
|
||||
</a>
|
||||
${showNav ? nav : ''}
|
||||
<div class="header-user">
|
||||
${isAdmin ? `
|
||||
<div class="alert-bell" id="alertBell" onclick="toggleAlertDropdown()" title="Alerts">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
|
||||
<span class="alert-badge" id="alertBadge" style="display:none">0</span>
|
||||
</div>
|
||||
<div class="alert-dropdown" id="alertDropdown" style="display:none">
|
||||
<div class="alert-dropdown-header">
|
||||
<strong>Alerts (24h)</strong>
|
||||
<span class="alert-dropdown-close" onclick="toggleAlertDropdown()">×</span>
|
||||
</div>
|
||||
<div class="alert-dropdown-body" id="alertDropdownBody">
|
||||
<span style="color:var(--text-muted);font-size:12px">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="user-info">
|
||||
<span class="user-avatar">${initials}</span>
|
||||
<span>${userName}</span>
|
||||
@@ -465,6 +521,60 @@ const themeScript = `
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var icon = document.getElementById('themeIcon');
|
||||
if (icon) icon.textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '\\u2600' : '\\u263E';
|
||||
// Load alert badge count
|
||||
if (document.getElementById('alertBell')) {
|
||||
fetch('/admin/api/alerts?unacked=1')
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
var badge = document.getElementById('alertBadge');
|
||||
if (badge && d.unacked_count > 0) {
|
||||
badge.textContent = d.unacked_count > 99 ? '99+' : d.unacked_count;
|
||||
badge.style.display = 'block';
|
||||
}
|
||||
}).catch(function(){});
|
||||
}
|
||||
});
|
||||
function toggleAlertDropdown() {
|
||||
var dd = document.getElementById('alertDropdown');
|
||||
if (!dd) return;
|
||||
var visible = dd.style.display !== 'none';
|
||||
dd.style.display = visible ? 'none' : 'block';
|
||||
if (!visible) loadAlertDropdown();
|
||||
}
|
||||
function loadAlertDropdown() {
|
||||
var body = document.getElementById('alertDropdownBody');
|
||||
if (!body) return;
|
||||
fetch('/admin/api/alerts')
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
if (!d.alerts || d.alerts.length === 0) {
|
||||
body.innerHTML = '<div style="padding:16px;text-align:center;color:var(--text-muted);font-size:12px">No alerts in last 24h</div>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = d.alerts.slice(0, 20).map(function(a){
|
||||
return '<div class="alert-item" onclick="ackAlert('+a.id+')">' +
|
||||
'<span class="alert-severity '+a.severity+'">'+a.severity+'</span>' +
|
||||
'<span>'+a.message.substring(0,120)+'</span>' +
|
||||
'<span class="alert-time">'+a.created_at+(a.acknowledged?' (acked)':'')+'</span></div>';
|
||||
}).join('');
|
||||
}).catch(function(e){ body.innerHTML = '<div style="padding:12px;color:var(--red);font-size:12px">Error loading alerts</div>'; });
|
||||
}
|
||||
function ackAlert(id) {
|
||||
fetch('/admin/api/alerts/'+id+'/ack', {method:'PUT'})
|
||||
.then(function(){ loadAlertDropdown(); })
|
||||
.catch(function(){});
|
||||
var badge = document.getElementById('alertBadge');
|
||||
if (badge) {
|
||||
var c = parseInt(badge.textContent) || 0;
|
||||
if (c > 1) { badge.textContent = c - 1; } else { badge.style.display = 'none'; }
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', function(e) {
|
||||
var dd = document.getElementById('alertDropdown');
|
||||
var bell = document.getElementById('alertBell');
|
||||
if (dd && bell && dd.style.display !== 'none' && !dd.contains(e.target) && !bell.contains(e.target)) {
|
||||
dd.style.display = 'none';
|
||||
}
|
||||
});
|
||||
<\/script>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user