Replace Dataddo/Aurora payroll pipeline with email-triggered notifications

SES receives Gusto payroll emails at payroll@int.seahaven.com, stores
to S3, Lambda parses and posts a combined Slack notification (employee
payroll + contractor payments in one message) after a 10-minute SQS
batching window.

Removes Aurora Serverless, notifyPayroll Lambda, and @aws-sdk/client-rds-data.
Adds mailparser, SQS delay queue, and processPayrollEmail Lambda.
This commit is contained in:
Adam Moussa 2026-04-30 14:04:48 -04:00
parent 8f409d0e63
commit fe7c9cebca
5 changed files with 1068 additions and 468 deletions

665
package-lock.json generated
View file

@ -9,11 +9,12 @@
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.600.0",
"@aws-sdk/client-rds-data": "^3.1037.0",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/client-sqs": "^3.600.0",
"@aws-sdk/client-ssm": "^3.600.0",
"@aws-sdk/lib-dynamodb": "^3.600.0",
"csv-parse": "^5.5.0"
"csv-parse": "^5.5.0",
"mailparser": "^3.7.1"
}
},
"node_modules/@aws-crypto/crc32": {
@ -272,56 +273,6 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-rds-data": {
"version": "3.1037.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-rds-data/-/client-rds-data-3.1037.0.tgz",
"integrity": "sha512-Q15Y/ZlIw/yQHtohMwb6s9KhiIIneQ1GNrewVAiXMy3mY9hK5D2w4AP7qAPPmOA8P3qypzHogfWs9+0ncsGJOw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.974.5",
"@aws-sdk/credential-provider-node": "^3.972.36",
"@aws-sdk/middleware-host-header": "^3.972.10",
"@aws-sdk/middleware-logger": "^3.972.10",
"@aws-sdk/middleware-recursion-detection": "^3.972.11",
"@aws-sdk/middleware-user-agent": "^3.972.35",
"@aws-sdk/region-config-resolver": "^3.972.13",
"@aws-sdk/types": "^3.973.8",
"@aws-sdk/util-endpoints": "^3.996.8",
"@aws-sdk/util-user-agent-browser": "^3.972.10",
"@aws-sdk/util-user-agent-node": "^3.973.21",
"@smithy/config-resolver": "^4.4.17",
"@smithy/core": "^3.23.17",
"@smithy/fetch-http-handler": "^5.3.17",
"@smithy/hash-node": "^4.2.14",
"@smithy/invalid-dependency": "^4.2.14",
"@smithy/middleware-content-length": "^4.2.14",
"@smithy/middleware-endpoint": "^4.4.32",
"@smithy/middleware-retry": "^4.5.5",
"@smithy/middleware-serde": "^4.2.20",
"@smithy/middleware-stack": "^4.2.14",
"@smithy/node-config-provider": "^4.3.14",
"@smithy/node-http-handler": "^4.6.1",
"@smithy/protocol-http": "^5.3.14",
"@smithy/smithy-client": "^4.12.13",
"@smithy/types": "^4.14.1",
"@smithy/url-parser": "^4.2.14",
"@smithy/util-base64": "^4.3.2",
"@smithy/util-body-length-browser": "^4.2.2",
"@smithy/util-body-length-node": "^4.2.3",
"@smithy/util-defaults-mode-browser": "^4.3.49",
"@smithy/util-defaults-mode-node": "^4.2.54",
"@smithy/util-endpoints": "^3.4.2",
"@smithy/util-middleware": "^4.2.14",
"@smithy/util-retry": "^4.3.4",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1027.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1027.0.tgz",
@ -388,6 +339,58 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-sqs": {
"version": "3.1039.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.1039.0.tgz",
"integrity": "sha512-U8rooijGy5OnSdxEaJ/sIxuGPgHfspxE8K/az22eJJ4qnO2XcVMtkHljvbfQG0yL3wd93XaypNSPI52okdTyCA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.974.7",
"@aws-sdk/credential-provider-node": "^3.972.38",
"@aws-sdk/middleware-host-header": "^3.972.10",
"@aws-sdk/middleware-logger": "^3.972.10",
"@aws-sdk/middleware-recursion-detection": "^3.972.11",
"@aws-sdk/middleware-sdk-sqs": "^3.972.22",
"@aws-sdk/middleware-user-agent": "^3.972.37",
"@aws-sdk/region-config-resolver": "^3.972.13",
"@aws-sdk/types": "^3.973.8",
"@aws-sdk/util-endpoints": "^3.996.8",
"@aws-sdk/util-user-agent-browser": "^3.972.10",
"@aws-sdk/util-user-agent-node": "^3.973.23",
"@smithy/config-resolver": "^4.4.17",
"@smithy/core": "^3.23.17",
"@smithy/fetch-http-handler": "^5.3.17",
"@smithy/hash-node": "^4.2.14",
"@smithy/invalid-dependency": "^4.2.14",
"@smithy/md5-js": "^4.2.14",
"@smithy/middleware-content-length": "^4.2.14",
"@smithy/middleware-endpoint": "^4.4.32",
"@smithy/middleware-retry": "^4.5.7",
"@smithy/middleware-serde": "^4.2.20",
"@smithy/middleware-stack": "^4.2.14",
"@smithy/node-config-provider": "^4.3.14",
"@smithy/node-http-handler": "^4.6.1",
"@smithy/protocol-http": "^5.3.14",
"@smithy/smithy-client": "^4.12.13",
"@smithy/types": "^4.14.1",
"@smithy/url-parser": "^4.2.14",
"@smithy/util-base64": "^4.3.2",
"@smithy/util-body-length-browser": "^4.2.2",
"@smithy/util-body-length-node": "^4.2.3",
"@smithy/util-defaults-mode-browser": "^4.3.49",
"@smithy/util-defaults-mode-node": "^4.2.54",
"@smithy/util-endpoints": "^3.4.2",
"@smithy/util-middleware": "^4.2.14",
"@smithy/util-retry": "^4.3.6",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-ssm": {
"version": "3.1027.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1027.0.tgz",
@ -440,13 +443,13 @@
}
},
"node_modules/@aws-sdk/core": {
"version": "3.974.5",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.5.tgz",
"integrity": "sha512-lMPlYlYfQdNZhlkJgnkmESwrY+hNh3PljmZ+37oAqLNdJ6rnILAwFSyc6B3bJeDOtMORNnMQIej0aTRuOlDyhQ==",
"version": "3.974.7",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.7.tgz",
"integrity": "sha512-YhRC90ofz5oolTJZlA8voU/oUrCB2azi8Usx51k8hhB5LpWbYQMMXKUqSqkoL0Cru+RQJgWTHpAfEDDIwfUhJw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.8",
"@aws-sdk/xml-builder": "^3.972.19",
"@aws-sdk/xml-builder": "^3.972.22",
"@smithy/core": "^3.23.17",
"@smithy/node-config-provider": "^4.3.14",
"@smithy/property-provider": "^4.2.14",
@ -456,7 +459,7 @@
"@smithy/types": "^4.14.1",
"@smithy/util-base64": "^4.3.2",
"@smithy/util-middleware": "^4.2.14",
"@smithy/util-retry": "^4.3.4",
"@smithy/util-retry": "^4.3.6",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
@ -478,12 +481,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.31",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.31.tgz",
"integrity": "sha512-X/yGB73LmDW/6MdDJGCDzZBUXnM3ys4vs9l+5ZTJmiEswDdP1OjeoAFlFjVGS9o4KB2wZWQ9KOfdVNSSK6Ep3w==",
"version": "3.972.33",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.33.tgz",
"integrity": "sha512-bJV7eViSJV6GSuuN+VIdNVPdwPsNSf75BiC2v5alPrjR/OCcqgKwSZInKbDFz9mNeizldsyf67jt6YSIiv53Cw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.5",
"@aws-sdk/core": "^3.974.7",
"@aws-sdk/types": "^3.973.8",
"@smithy/property-provider": "^4.2.14",
"@smithy/types": "^4.14.1",
@ -494,12 +497,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.33",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.33.tgz",
"integrity": "sha512-c0ZF+lwoWVvX5iCaGKL5T/4DnIw88CGqxA0BcBs3U86mIp5EZYPVg+KSPkMXOyokmADvNewiMUfSG2uFwjRp0g==",
"version": "3.972.35",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.35.tgz",
"integrity": "sha512-x/BQGEIdq0oI+4WxLjKmnQvT7CnF9r8ezdGt7wXwxb7ckHXQz0Zmgxt8v3Ne0JaT3R5YefmuybHX6E8EnsDXyA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.5",
"@aws-sdk/core": "^3.974.7",
"@aws-sdk/types": "^3.973.8",
"@smithy/fetch-http-handler": "^5.3.17",
"@smithy/node-http-handler": "^4.6.1",
@ -515,19 +518,19 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.972.35",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.35.tgz",
"integrity": "sha512-jsU4u/cRkKFLKQS0k918FQ27fzXLG5ENiLWQMYE6581zLeI2hWh04ptlrvZMB3wJT/5d+vSzJk74X1CMFr4y8Q==",
"version": "3.972.37",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.37.tgz",
"integrity": "sha512-eUTpmWfd/BKsq9medhCRcu+GRAhFP2Zrn7/2jKDHHOOjCkhrMoTp/t4cEthqFoG7gE0VGp5wUxrXTdvBCmSmJg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.5",
"@aws-sdk/credential-provider-env": "^3.972.31",
"@aws-sdk/credential-provider-http": "^3.972.33",
"@aws-sdk/credential-provider-login": "^3.972.35",
"@aws-sdk/credential-provider-process": "^3.972.31",
"@aws-sdk/credential-provider-sso": "^3.972.35",
"@aws-sdk/credential-provider-web-identity": "^3.972.35",
"@aws-sdk/nested-clients": "^3.997.3",
"@aws-sdk/core": "^3.974.7",
"@aws-sdk/credential-provider-env": "^3.972.33",
"@aws-sdk/credential-provider-http": "^3.972.35",
"@aws-sdk/credential-provider-login": "^3.972.37",
"@aws-sdk/credential-provider-process": "^3.972.33",
"@aws-sdk/credential-provider-sso": "^3.972.37",
"@aws-sdk/credential-provider-web-identity": "^3.972.37",
"@aws-sdk/nested-clients": "^3.997.5",
"@aws-sdk/types": "^3.973.8",
"@smithy/credential-provider-imds": "^4.2.14",
"@smithy/property-provider": "^4.2.14",
@ -540,13 +543,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.35",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.35.tgz",
"integrity": "sha512-5oa3j0cA50jPqgNhZ9XdJVopuzUf1klRb28/2MfLYWWiPi9DRVvbrBWT+DidbHTT36520VuXZJahQwR+YgSjrg==",
"version": "3.972.37",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.37.tgz",
"integrity": "sha512-Ty68y8ISSC+g5Q3D0K8uAaoINwvfaOslnNpsF/LgVUxyosYXHawcK2yV4HLXDVugiTTYLQfJfcw0ce5meAGkKw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.5",
"@aws-sdk/nested-clients": "^3.997.3",
"@aws-sdk/core": "^3.974.7",
"@aws-sdk/nested-clients": "^3.997.5",
"@aws-sdk/types": "^3.973.8",
"@smithy/property-provider": "^4.2.14",
"@smithy/protocol-http": "^5.3.14",
@ -559,17 +562,17 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.36",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.36.tgz",
"integrity": "sha512-4nT2T8Z7vH8KE9EdjEsuIlHpZSlcaK2PrKbQBjuUGU46BCCzF3WvP0u0Uiosni3Ykmmn4rWLVawoOCLotUtCbg==",
"version": "3.972.38",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.38.tgz",
"integrity": "sha512-BQ9XYnBDVxR2HuV5huXYQYF/PZMTsY+EnwfGnCU2cA8Zw63XpkOtPY8WqiMIZMQCrKPQQEiFURS/o9CIolRLqg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.31",
"@aws-sdk/credential-provider-http": "^3.972.33",
"@aws-sdk/credential-provider-ini": "^3.972.35",
"@aws-sdk/credential-provider-process": "^3.972.31",
"@aws-sdk/credential-provider-sso": "^3.972.35",
"@aws-sdk/credential-provider-web-identity": "^3.972.35",
"@aws-sdk/credential-provider-env": "^3.972.33",
"@aws-sdk/credential-provider-http": "^3.972.35",
"@aws-sdk/credential-provider-ini": "^3.972.37",
"@aws-sdk/credential-provider-process": "^3.972.33",
"@aws-sdk/credential-provider-sso": "^3.972.37",
"@aws-sdk/credential-provider-web-identity": "^3.972.37",
"@aws-sdk/types": "^3.973.8",
"@smithy/credential-provider-imds": "^4.2.14",
"@smithy/property-provider": "^4.2.14",
@ -582,12 +585,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.31",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.31.tgz",
"integrity": "sha512-eKeT4MXumpBJsrDLCYcSzIkFPVTFn/es7It2oogp2OhU/ic7P/+xzFpQx9ZhwtXS57Mc5S42BPWi7lHmvs/nYg==",
"version": "3.972.33",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.33.tgz",
"integrity": "sha512-yfjGksI9WQbdMObb0VeLXqzTLI+a0qXLJT9gCDiv0+X/xjPpI3mTz6a5FibrhpuEKIe0gSgvs3MaoFZy5cx4WA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.5",
"@aws-sdk/core": "^3.974.7",
"@aws-sdk/types": "^3.973.8",
"@smithy/property-provider": "^4.2.14",
"@smithy/shared-ini-file-loader": "^4.4.9",
@ -599,14 +602,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.972.35",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.35.tgz",
"integrity": "sha512-bCuBdfnj0KGDMdLp6utMTLiJcFN2ek9EgZinxQZZSc3FxjJ/HSqeqab2cjbnoNfy8RM6suDCsRkmVY1izp9I+A==",
"version": "3.972.37",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.37.tgz",
"integrity": "sha512-fpwE+20ntpp3i9Xb9vUuQfXLDKYHH+5I2V+ZG96SX1nBzrruhy10RXDgmN7t1etOz3c55stlA3TeQASUA451NQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.5",
"@aws-sdk/nested-clients": "^3.997.3",
"@aws-sdk/token-providers": "3.1036.0",
"@aws-sdk/core": "^3.974.7",
"@aws-sdk/nested-clients": "^3.997.5",
"@aws-sdk/token-providers": "3.1039.0",
"@aws-sdk/types": "^3.973.8",
"@smithy/property-provider": "^4.2.14",
"@smithy/shared-ini-file-loader": "^4.4.9",
@ -618,13 +621,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.35",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.35.tgz",
"integrity": "sha512-swW6Bwvl8lanyEMtZOWE/oR6yqcRQH4HTQZUVsnDVgoXvRjRywpYpLv2BWwjUFyjPrqsdX6FeTkf4tMSe/qFTQ==",
"version": "3.972.37",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.37.tgz",
"integrity": "sha512-aryawqyebf+3WhAFNHfF62rekFpYtVcVN7dQ89qnAWsa4n5hJst8qBG6gXC24WHtW7Nnhkf9ScYnjwo0Brn3bw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.5",
"@aws-sdk/nested-clients": "^3.997.3",
"@aws-sdk/core": "^3.974.7",
"@aws-sdk/nested-clients": "^3.997.5",
"@aws-sdk/types": "^3.973.8",
"@smithy/property-provider": "^4.2.14",
"@smithy/shared-ini-file-loader": "^4.4.9",
@ -820,12 +823,12 @@
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.34",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.34.tgz",
"integrity": "sha512-/UL96JKjsjdodcRRMKl99tLQvK6Oi9ptLC9iU1yiTF/ruaDX0mtBBtnLNZDxIZRJOCVOtB49ed1YaTadqygk8Q==",
"version": "3.972.36",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.36.tgz",
"integrity": "sha512-YhPix+0x/MdQrb1Ug1GDKeS5fqylIy+naz800asX8II4jqfTk2KY2KhmmYCwZcky8YWtRQQwWCGdoqeAnip8Uw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.5",
"@aws-sdk/core": "^3.974.7",
"@aws-sdk/types": "^3.973.8",
"@aws-sdk/util-arn-parser": "^3.972.3",
"@smithy/core": "^3.23.17",
@ -844,6 +847,23 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-sqs": {
"version": "3.972.22",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.22.tgz",
"integrity": "sha512-DtR3mEiOUJcnEX/QuXmvbJto6xvQzp2ftnHb29c0aQYdmmzbKf0gsu9ovx1i/yy4ZR6m0rttTucS0iiP32dlGA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.8",
"@smithy/smithy-client": "^4.12.13",
"@smithy/types": "^4.14.1",
"@smithy/util-hex-encoding": "^4.2.2",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-ssec": {
"version": "3.972.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.9.tgz",
@ -859,18 +879,18 @@
}
},
"node_modules/@aws-sdk/middleware-user-agent": {
"version": "3.972.35",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.35.tgz",
"integrity": "sha512-hOFWNOjVmOocpRlrU04nYxjMOeoe0Obu5AXEuhB8zblMCPl3cG1hdluQCZERRKFyhMQjwZnDbhSHjoMUjetFGw==",
"version": "3.972.37",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.37.tgz",
"integrity": "sha512-N1oNpdiLoVAWYD3WFBnUi3LlfoDA06ZHo4ozyjbsJNLvILzvt//0CnR8N+CZ0NWeYgVB/5V59ivixHCWCx2ALw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.5",
"@aws-sdk/core": "^3.974.7",
"@aws-sdk/types": "^3.973.8",
"@aws-sdk/util-endpoints": "^3.996.8",
"@smithy/core": "^3.23.17",
"@smithy/protocol-http": "^5.3.14",
"@smithy/types": "^4.14.1",
"@smithy/util-retry": "^4.3.4",
"@smithy/util-retry": "^4.3.6",
"tslib": "^2.6.2"
},
"engines": {
@ -878,24 +898,24 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.3",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.3.tgz",
"integrity": "sha512-SivE6GP228IVgfsrr2c/vqTg95X0Qj39Yw4uIrcddpkUzIltNMoNOR62leHOLhODfjv9K8X2mPTwS69A5kT0nQ==",
"version": "3.997.5",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.5.tgz",
"integrity": "sha512-jGFr6DxtcMTmzOkG/a0jCZYv4BBDmeNYVeO+/memSoDkYCJu4Y58xviYmzwJfYyIVSts+X/BVjJm1uGBnwHEMg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.974.5",
"@aws-sdk/core": "^3.974.7",
"@aws-sdk/middleware-host-header": "^3.972.10",
"@aws-sdk/middleware-logger": "^3.972.10",
"@aws-sdk/middleware-recursion-detection": "^3.972.11",
"@aws-sdk/middleware-user-agent": "^3.972.35",
"@aws-sdk/middleware-user-agent": "^3.972.37",
"@aws-sdk/region-config-resolver": "^3.972.13",
"@aws-sdk/signature-v4-multi-region": "^3.996.22",
"@aws-sdk/signature-v4-multi-region": "^3.996.24",
"@aws-sdk/types": "^3.973.8",
"@aws-sdk/util-endpoints": "^3.996.8",
"@aws-sdk/util-user-agent-browser": "^3.972.10",
"@aws-sdk/util-user-agent-node": "^3.973.21",
"@aws-sdk/util-user-agent-node": "^3.973.23",
"@smithy/config-resolver": "^4.4.17",
"@smithy/core": "^3.23.17",
"@smithy/fetch-http-handler": "^5.3.17",
@ -903,7 +923,7 @@
"@smithy/invalid-dependency": "^4.2.14",
"@smithy/middleware-content-length": "^4.2.14",
"@smithy/middleware-endpoint": "^4.4.32",
"@smithy/middleware-retry": "^4.5.5",
"@smithy/middleware-retry": "^4.5.7",
"@smithy/middleware-serde": "^4.2.20",
"@smithy/middleware-stack": "^4.2.14",
"@smithy/node-config-provider": "^4.3.14",
@ -919,7 +939,7 @@
"@smithy/util-defaults-mode-node": "^4.2.54",
"@smithy/util-endpoints": "^3.4.2",
"@smithy/util-middleware": "^4.2.14",
"@smithy/util-retry": "^4.3.4",
"@smithy/util-retry": "^4.3.6",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
@ -944,12 +964,12 @@
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.22",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.22.tgz",
"integrity": "sha512-/rXhMXteD+BqhFd0nYprAgcZ/KtU+963uftPqd3tiFcFfooHZINXUGtOmo2SQjRVauCTNqIEzkwuSETdZFqTTA==",
"version": "3.996.24",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.24.tgz",
"integrity": "sha512-amP7tLikppN940wbBFISYqiuzVmpzMS9U3mcgtmVLjX4fdWI/SNCvrXv6ZxfVzTT4cT0rPKOLhFah2xLwzREWw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/middleware-sdk-s3": "^3.972.34",
"@aws-sdk/middleware-sdk-s3": "^3.972.36",
"@aws-sdk/types": "^3.973.8",
"@smithy/protocol-http": "^5.3.14",
"@smithy/signature-v4": "^5.3.14",
@ -961,13 +981,13 @@
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1036.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1036.0.tgz",
"integrity": "sha512-aNSJ6jjDYayxN9ZA1JpycVScX93Lx03kKZ1EXt3DGOTahcWVLJj3oLAlop0xKP+vP2Ga2t49p1tEaMkTbCCaZA==",
"version": "3.1039.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1039.0.tgz",
"integrity": "sha512-NMSFL2HwkAOoCeLCQiqoOq5pT3vVbSjww2QZTuYgYknVwhhv125PSDzZIcL5EYnlxuPWjEOdauZK+FspkZDVdw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.5",
"@aws-sdk/nested-clients": "^3.997.3",
"@aws-sdk/core": "^3.974.7",
"@aws-sdk/nested-clients": "^3.997.5",
"@aws-sdk/types": "^3.973.8",
"@smithy/property-provider": "^4.2.14",
"@smithy/shared-ini-file-loader": "^4.4.9",
@ -1059,12 +1079,12 @@
}
},
"node_modules/@aws-sdk/util-user-agent-node": {
"version": "3.973.21",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.21.tgz",
"integrity": "sha512-Av4UHTcAWgdvbN0IP9pbtf4Qa1+6LtJqQdZWj5pLn5J67w0pnJJAZZ+7JPPcj2KN3378zD2JDM9DwJKEyvyMTQ==",
"version": "3.973.23",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.23.tgz",
"integrity": "sha512-gGwq8L2Euw0aNG6Ey4EktiAo3fSCVoDy1CaBIthd+oeaKHPXUrNaApMewQ6La5Hv0lcznOtECZaNvYyc5LXXfA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/middleware-user-agent": "^3.972.35",
"@aws-sdk/middleware-user-agent": "^3.972.37",
"@aws-sdk/types": "^3.973.8",
"@smithy/node-config-provider": "^4.3.14",
"@smithy/types": "^4.14.1",
@ -1084,13 +1104,14 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.19",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.19.tgz",
"integrity": "sha512-Cw8IOMdBUEIl8ZlhRC3Dc/E64D5B5/8JhV6vhPLiPfJwcRC84S6F8aBOIi/N4vR9ZyA4I5Cc0Ateb/9EHaJXeQ==",
"version": "3.972.22",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz",
"integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==",
"license": "Apache-2.0",
"dependencies": {
"@nodable/entities": "2.1.0",
"@smithy/types": "^4.14.1",
"fast-xml-parser": "5.7.1",
"fast-xml-parser": "5.7.2",
"tslib": "^2.6.2"
},
"engines": {
@ -1118,6 +1139,19 @@
],
"license": "MIT"
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"selderee": "^0.11.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@smithy/chunked-blob-reader": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz",
@ -1353,12 +1387,12 @@
}
},
"node_modules/@smithy/md5-js": {
"version": "4.2.13",
"resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.13.tgz",
"integrity": "sha512-cNm7I9NXolFxtS20ojROddOEpSAeI1Obq6pd1Kj5HtHws3s9Fkk8DdHDfQSs5KuxCewZuVK6UqrJnfJmiMzDuQ==",
"version": "4.2.14",
"resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.14.tgz",
"integrity": "sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.14.0",
"@smithy/types": "^4.14.1",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
@ -1400,19 +1434,19 @@
}
},
"node_modules/@smithy/middleware-retry": {
"version": "4.5.5",
"resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.5.tgz",
"integrity": "sha512-wnYOpB5vATFKWrY2Z9Alb0KhjZI6AbzU6Fbz3Hq2GnURdRYWB4q+qWivQtSTwXcmWUA3MZ6krfwL6Cq5MAbxsA==",
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.7.tgz",
"integrity": "sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.23.17",
"@smithy/node-config-provider": "^4.3.14",
"@smithy/protocol-http": "^5.3.14",
"@smithy/service-error-classification": "^4.3.0",
"@smithy/service-error-classification": "^4.3.1",
"@smithy/smithy-client": "^4.12.13",
"@smithy/types": "^4.14.1",
"@smithy/util-middleware": "^4.2.14",
"@smithy/util-retry": "^4.3.4",
"@smithy/util-retry": "^4.3.6",
"@smithy/uuid": "^1.1.2",
"tslib": "^2.6.2"
},
@ -1532,9 +1566,9 @@
}
},
"node_modules/@smithy/service-error-classification": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.0.tgz",
"integrity": "sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz",
"integrity": "sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.14.1"
@ -1755,12 +1789,12 @@
}
},
"node_modules/@smithy/util-retry": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.4.tgz",
"integrity": "sha512-FY1UQQ1VFmMwiYp1GVS4MeaGD5O0blLNYK0xCRHU+mJgeoH/hSY8Ld8sJWKQ6uznkh14HveRGQJncgPyNl9J+A==",
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.6.tgz",
"integrity": "sha512-p6/FO1n2KxMeQyna067i0uJ6TSbb165ZhnRtCpWh4Foxqbfc6oW+XITaL8QkFJj3KFnDe2URt4gOhgU06EP9ew==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/service-error-classification": "^4.3.0",
"@smithy/service-error-classification": "^4.3.1",
"@smithy/types": "^4.14.1",
"tslib": "^2.6.2"
},
@ -1837,6 +1871,41 @@
"node": ">=18.0.0"
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1"
}
},
"node_modules/@zone-eu/mailsplit/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@zone-eu/mailsplit/node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
@ -1849,6 +1918,91 @@
"integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/fast-xml-builder": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
@ -1865,9 +2019,9 @@
}
},
"node_modules/fast-xml-parser": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz",
"integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==",
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz",
"integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==",
"funding": [
{
"type": "github",
@ -1885,6 +2039,126 @@
"fxparser": "src/cli/cli.js"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
"license": "MIT",
"dependencies": {
"@selderee/plugin-htmlparser2": "^0.11.0",
"deepmerge": "^4.3.1",
"dom-serializer": "^2.0.0",
"htmlparser2": "^8.0.2",
"selderee": "^0.11.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/leac": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/mailparser": {
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz",
"integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"he": "1.2.0",
"html-to-text": "9.0.5",
"iconv-lite": "0.7.2",
"libmime": "5.3.8",
"linkify-it": "5.0.0",
"nodemailer": "8.0.5",
"punycode.js": "2.3.1",
"tlds": "1.261.0"
}
},
"node_modules/mnemonist": {
"version": "0.38.3",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz",
@ -1894,12 +2168,34 @@
"obliterator": "^1.6.1"
}
},
"node_modules/nodemailer": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/obliterator": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz",
"integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==",
"license": "MIT"
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
"license": "MIT",
"dependencies": {
"leac": "^0.6.0",
"peberminta": "^0.9.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/path-expression-matcher": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
@ -1915,6 +2211,42 @@
"node": ">=14.0.0"
}
},
"node_modules/peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/selderee": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
"license": "MIT",
"dependencies": {
"parseley": "^0.12.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/strnum": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
@ -1927,11 +2259,26 @@
],
"license": "MIT"
},
"node_modules/tlds": {
"version": "1.261.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
"license": "MIT",
"bin": {
"tlds": "bin.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
}
}
}

View file

@ -8,10 +8,11 @@
],
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.600.0",
"@aws-sdk/client-rds-data": "^3.1037.0",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/client-sqs": "^3.600.0",
"@aws-sdk/client-ssm": "^3.600.0",
"@aws-sdk/lib-dynamodb": "^3.600.0",
"csv-parse": "^5.5.0"
"csv-parse": "^5.5.0",
"mailparser": "^3.7.1"
}
}

View file

@ -1,187 +0,0 @@
import { RDSDataClient, ExecuteStatementCommand } from "@aws-sdk/client-rds-data";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
const rds = new RDSDataClient();
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
const ssm = new SSMClient();
const CLUSTER_ARN = process.env.AURORA_CLUSTER_ARN;
const SECRET_ARN = process.env.AURORA_SECRET_ARN;
const DATABASE = "payroll";
const TABLE_NAME = process.env.TABLE_NAME;
const CHANNEL_ID = "C0AV5RBMYKU";
let cachedToken;
async function getSlackToken() {
if (cachedToken) return cachedToken;
const { Parameter } = await ssm.send(
new GetParameterCommand({ Name: process.env.SLACK_BOT_TOKEN_PARAM, WithDecryption: true })
);
cachedToken = Parameter.Value;
return cachedToken;
}
async function sql(query) {
const { records } = await rds.send(
new ExecuteStatementCommand({
resourceArn: CLUSTER_ARN,
secretArn: SECRET_ARN,
database: DATABASE,
sql: query,
})
);
return records || [];
}
function extractValue(field) {
if (field.stringValue !== undefined) return field.stringValue;
if (field.longValue !== undefined) return field.longValue;
if (field.doubleValue !== undefined) return field.doubleValue;
if (field.booleanValue !== undefined) return field.booleanValue;
if (field.isNull) return null;
return null;
}
const formatCurrency = (v) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(Number(v || 0));
const formatDate = (ts) => {
const d = new Date(ts);
return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" });
};
async function getNotifiedPayrolls() {
const result = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { pk: "PAYROLL_NOTIFIED" } })
);
return result.Item?.checkDates || [];
}
async function markNotified(checkDate) {
const existing = await getNotifiedPayrolls();
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: { pk: "PAYROLL_NOTIFIED", checkDates: [...existing, checkDate] },
})
);
}
async function buildPayrollMessage(row) {
const checkDate = extractValue(row[0]);
const companyDebit = extractValue(row[1]);
const grossPay = extractValue(row[2]);
const netPay = extractValue(row[3]);
const employerTaxes = extractValue(row[4]);
const reimbursements = extractValue(row[5]);
const benefits = extractValue(row[6]);
const contractorRows = await sql(
`SELECT COALESCE(SUM(payments_wage_total), 0), COALESCE(SUM(payments_reimbursement), 0) FROM contractor_payments WHERE payments_date = '${checkDate}'`
);
const contractorWages = contractorRows.length > 0 ? extractValue(contractorRows[0][0]) : 0;
const contractorReimbursements = contractorRows.length > 0 ? extractValue(contractorRows[0][1]) : 0;
const contractorTotal = contractorWages + contractorReimbursements;
const totalGross = grossPay + contractorTotal;
const cashRequirement = companyDebit + contractorTotal + benefits;
const blocks = [
{
type: "header",
text: { type: "plain_text", text: `Payroll Processed — ${formatDate(checkDate)}` },
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Check Date*\n${formatDate(checkDate)}` },
{ type: "mrkdwn", text: `*Total Gross Pay*\n${formatCurrency(totalGross)}` },
],
},
{ type: "divider" },
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Employee Gross*\n${formatCurrency(grossPay)}` },
{ type: "mrkdwn", text: `*Contractor Gross*\n${formatCurrency(contractorTotal)}` },
],
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Employee Net Pay*\n${formatCurrency(netPay)}` },
{ type: "mrkdwn", text: `*Reimbursements*\n${formatCurrency(reimbursements)}` },
],
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Employer Taxes*\n${formatCurrency(employerTaxes)}` },
{ type: "mrkdwn", text: `*Benefits (401k)*\n${formatCurrency(benefits)}` },
],
},
{ type: "divider" },
{
type: "section",
text: {
type: "mrkdwn",
text: `:moneybag: *Total Bank Withdrawal on ${formatDate(checkDate)}: ${formatCurrency(cashRequirement)}*`,
},
},
];
return { checkDate, blocks };
}
export const handler = async () => {
const payrollRows = await sql(
"SELECT check_date, company_debit, gross_pay, net_pay, employer_taxes, reimbursements, benefits FROM payroll WHERE company_debit > 0 ORDER BY check_date DESC"
);
if (payrollRows.length === 0) {
console.log("No processed payrolls found");
return { statusCode: 200, body: "No payrolls" };
}
const notified = await getNotifiedPayrolls();
const newPayrolls = payrollRows.filter(
(row) => !notified.includes(extractValue(row[0]))
);
if (newPayrolls.length === 0) {
console.log("All payrolls already notified");
return { statusCode: 200, body: "Already notified" };
}
const token = await getSlackToken();
for (const row of newPayrolls) {
const { checkDate, blocks } = await buildPayrollMessage(row);
const res = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
channel: CHANNEL_ID,
blocks,
text: `Payroll processed for ${formatDate(checkDate)}`,
}),
});
const data = await res.json();
if (!data.ok) {
console.error("Slack post failed:", data.error);
throw new Error(`Slack API error: ${data.error}`);
}
await markNotified(checkDate);
console.log(`Notified for check date: ${checkDate}`);
}
return { statusCode: 200, body: `Notified ${newPayrolls.length} payroll(s)` };
};

464
src/processPayrollEmail.js Normal file
View file

@ -0,0 +1,464 @@
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
ScanCommand,
BatchWriteCommand,
} from "@aws-sdk/lib-dynamodb";
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
import { simpleParser } from "mailparser";
const s3 = new S3Client();
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
const sqs = new SQSClient();
const ssm = new SSMClient();
const TABLE_NAME = process.env.TABLE_NAME;
const CHANNEL_ID = process.env.PAYROLL_CHANNEL_ID;
const QUEUE_URL = process.env.CONTRACTOR_BATCH_QUEUE_URL;
let cachedToken;
async function getSlackToken() {
if (cachedToken) return cachedToken;
const { Parameter } = await ssm.send(
new GetParameterCommand({
Name: process.env.SLACK_BOT_TOKEN_PARAM,
WithDecryption: true,
})
);
cachedToken = Parameter.Value;
return cachedToken;
}
const formatCurrency = (v) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(Number(v || 0));
function classifyEmail(from, subject, text) {
const fromAddr = (from?.text || from || "").toLowerCase();
const subj = (subject || "").toLowerCase();
if (
fromAddr.includes("automated@gusto.com") &&
subj.includes("payroll confirmation")
) {
return "employee";
}
if (
fromAddr.includes("gustonoreply@gusto.com") &&
subj.includes("payment confirmation")
) {
return "contractor";
}
const strippedSubj = subj.replace(/^fwd?:\s*/i, "");
const body = (text || "").toLowerCase();
const bodyMentionsGusto = body.includes("gusto.com");
if (bodyMentionsGusto && strippedSubj.includes("payroll confirmation")) {
return "employee";
}
if (bodyMentionsGusto && strippedSubj.includes("payment confirmation")) {
return "contractor";
}
return null;
}
function parseDateFromSubject(subject) {
const m = subject.match(/:\s*(\w+,\s*\w+\s+\d+)\s+(?:payroll|payment)\s+confirmation/i);
if (!m) return null;
const raw = m[1].trim();
const year = new Date().getFullYear();
const d = new Date(`${raw}, ${year}`);
if (isNaN(d)) return null;
return {
display: raw,
iso: d.toISOString().slice(0, 10),
};
}
function parseEmployeePayroll(subject, text) {
const checkDate = parseDateFromSubject(subject);
if (!checkDate) return null;
const norm = text.replace(/\n/g, " ").replace(/\s+/g, " ");
const debitMatch = norm.match(
/we.ll debit \$([\d,]+\.\d{2}) from the bank account ending in (\d+)/i
);
if (!debitMatch) return null;
const totalDebit = parseFloat(debitMatch[1].replace(/,/g, ""));
const bankSuffix = debitMatch[2];
const payPeriodMatch = norm.match(/payroll for the (.+?)\s+pay period/i);
const netPayMatch = norm.match(
/\$([\d,]+\.\d{2}) will be for your employee net pay/i
);
const taxesMatch = norm.match(/\$([\d,]+\.\d{2}) will be for taxes/i);
const reimbursementsMatch = norm.match(
/\$([\d,]+\.\d{2}) will be for reimbursements/i
);
return {
checkDate: checkDate.display,
dateKey: checkDate.iso,
totalDebit,
bankSuffix,
payPeriod: payPeriodMatch ? payPeriodMatch[1].trim() : null,
netPay: netPayMatch ? parseFloat(netPayMatch[1].replace(/,/g, "")) : null,
taxes: taxesMatch ? parseFloat(taxesMatch[1].replace(/,/g, "")) : null,
reimbursements: reimbursementsMatch
? parseFloat(reimbursementsMatch[1].replace(/,/g, ""))
: null,
};
}
function parseContractorPayment(subject, text) {
const checkDate = parseDateFromSubject(subject);
if (!checkDate) return null;
const norm = text.replace(/\n/g, " ").replace(/\s+/g, " ");
const debitMatch = norm.match(
/we.ll debit \$([\d,]+\.\d{2}) from the bank account ending in (\d+)/i
);
if (!debitMatch) return null;
const totalDebit = parseFloat(debitMatch[1].replace(/,/g, ""));
const bankSuffix = debitMatch[2];
const nameMatch = norm.match(/at that time for (.+?) to be paid on/i);
const contractorName = nameMatch ? nameMatch[1].trim() : "Unknown contractor";
return {
checkDate: checkDate.display,
dateKey: checkDate.iso,
totalDebit,
bankSuffix,
contractorName,
};
}
function buildCombinedBlocks(dateDisplay, employee, contractors) {
const blocks = [
{
type: "header",
text: {
type: "plain_text",
text: `Payroll Processed — ${dateDisplay}`,
},
},
];
if (employee) {
const topFields = [
{ type: "mrkdwn", text: `*Check Date*\n${employee.checkDate}` },
];
if (employee.payPeriod) {
topFields.unshift({
type: "mrkdwn",
text: `*Pay Period*\n${employee.payPeriod}`,
});
}
blocks.push({ type: "section", fields: topFields });
blocks.push({ type: "divider" });
const detailFields = [];
if (employee.netPay != null) {
detailFields.push({
type: "mrkdwn",
text: `*Employee Net Pay*\n${formatCurrency(employee.netPay)}`,
});
}
if (employee.taxes != null) {
detailFields.push({
type: "mrkdwn",
text: `*Taxes*\n${formatCurrency(employee.taxes)}`,
});
}
if (detailFields.length) blocks.push({ type: "section", fields: detailFields });
const extraFields = [];
if (employee.reimbursements != null) {
extraFields.push({
type: "mrkdwn",
text: `*Reimbursements*\n${formatCurrency(employee.reimbursements)}`,
});
}
extraFields.push({
type: "mrkdwn",
text: `*Bank Account*\n...${employee.bankSuffix}`,
});
blocks.push({ type: "section", fields: extraFields });
}
if (contractors.length > 0) {
blocks.push({ type: "divider" });
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: `*Contractor Payments*`,
},
});
for (const c of contractors) {
blocks.push({
type: "section",
fields: [
{ type: "mrkdwn", text: `*Contractor*\n${c.contractorName}` },
{ type: "mrkdwn", text: `*Amount*\n${formatCurrency(c.totalDebit)}` },
],
});
}
}
const grandTotal =
(employee ? employee.totalDebit : 0) +
contractors.reduce((sum, c) => sum + c.totalDebit, 0);
blocks.push({ type: "divider" });
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: `:moneybag: *Total Bank Debit: ${formatCurrency(grandTotal)}*`,
},
});
blocks.push({
type: "context",
elements: [
{ type: "mrkdwn", text: "Source: Gusto payroll confirmation emails" },
],
});
return { blocks, grandTotal };
}
async function postSlack(token, blocks, text) {
const res = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ channel: CHANNEL_ID, blocks, text }),
});
const data = await res.json();
if (!data.ok) {
console.error("Slack post failed:", data.error);
throw new Error(`Slack API error: ${data.error}`);
}
return data.ts;
}
const ttl90Days = () => Math.floor(Date.now() / 1000) + 90 * 24 * 60 * 60;
async function handleS3Event(record) {
const bucket = record.s3.bucket.name;
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));
const { Body } = await s3.send(
new GetObjectCommand({ Bucket: bucket, Key: key })
);
const rawEmail = await Body.transformToByteArray();
const parsed = await simpleParser(Buffer.from(rawEmail));
const type = classifyEmail(parsed.from, parsed.subject, parsed.text);
if (!type) {
console.log("Unrecognized email, skipping:", parsed.subject);
return;
}
if (type === "employee") {
const data = parseEmployeePayroll(parsed.subject, parsed.text);
if (!data) {
console.error("Failed to parse employee payroll:", parsed.subject);
return;
}
const pk = `PAYROLL_EMAIL#employee#${data.dateKey}`;
const existing = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { pk } })
);
if (existing.Item) {
console.log(`Already recorded: employee ${data.dateKey}`);
return;
}
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: {
pk,
type: "employee",
status: "pending",
checkDate: data.checkDate,
dateKey: data.dateKey,
totalDebit: data.totalDebit,
bankSuffix: data.bankSuffix,
payPeriod: data.payPeriod,
netPay: data.netPay,
taxes: data.taxes,
reimbursements: data.reimbursements,
processedAt: new Date().toISOString(),
ttl: ttl90Days(),
},
})
);
await sqs.send(
new SendMessageCommand({
QueueUrl: QUEUE_URL,
MessageBody: JSON.stringify({ dateKey: data.dateKey }),
})
);
console.log(`Queued employee payroll for ${data.dateKey}`);
}
if (type === "contractor") {
const data = parseContractorPayment(parsed.subject, parsed.text);
if (!data) {
console.error("Failed to parse contractor payment:", parsed.subject);
return;
}
const pk = `PAYROLL_EMAIL#contractor#${data.dateKey}#${data.bankSuffix}`;
const existing = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { pk } })
);
if (existing.Item) {
console.log(
`Already recorded: contractor ${data.contractorName} ${data.dateKey}`
);
return;
}
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: {
pk,
type: "contractor",
status: "pending",
checkDate: data.checkDate,
dateKey: data.dateKey,
totalDebit: data.totalDebit,
bankSuffix: data.bankSuffix,
contractorName: data.contractorName,
processedAt: new Date().toISOString(),
ttl: ttl90Days(),
},
})
);
await sqs.send(
new SendMessageCommand({
QueueUrl: QUEUE_URL,
MessageBody: JSON.stringify({ dateKey: data.dateKey }),
})
);
console.log(
`Queued contractor: ${data.contractorName} for ${data.dateKey}`
);
}
}
async function handleSqsEvent(sqsRecord) {
const { dateKey } = JSON.parse(sqsRecord.body);
const batchPk = `PAYROLL_BATCH#${dateKey}`;
const batchCheck = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { pk: batchPk } })
);
if (batchCheck.Item) {
console.log(`Batch already posted for ${dateKey}`);
return;
}
const { Items } = await ddb.send(
new ScanCommand({
TableName: TABLE_NAME,
FilterExpression: "begins_with(pk, :prefix) AND #s = :pending",
ExpressionAttributeNames: { "#s": "status" },
ExpressionAttributeValues: {
":prefix": `PAYROLL_EMAIL#`,
":pending": "pending",
},
})
);
const pending = (Items || []).filter((i) => i.dateKey === dateKey);
if (pending.length === 0) {
console.log(`No pending items for ${dateKey}`);
return;
}
const employee = pending.find((i) => i.type === "employee") || null;
const contractors = pending.filter((i) => i.type === "contractor");
const dateDisplay = pending[0].checkDate;
const token = await getSlackToken();
const { blocks, grandTotal } = buildCombinedBlocks(dateDisplay, employee, contractors);
const slackTs = await postSlack(
token,
blocks,
`Payroll processed for ${dateDisplay}: ${formatCurrency(grandTotal)}`
);
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: {
pk: batchPk,
dateKey,
checkDate: dateDisplay,
employeeCount: employee ? 1 : 0,
contractorCount: contractors.length,
totalDebit: grandTotal,
slackTs,
processedAt: new Date().toISOString(),
ttl: ttl90Days(),
},
})
);
const updateBatch = pending.map((item) => ({
PutRequest: {
Item: { ...item, status: "notified" },
},
}));
for (let i = 0; i < updateBatch.length; i += 25) {
await ddb.send(
new BatchWriteCommand({
RequestItems: { [TABLE_NAME]: updateBatch.slice(i, i + 25) },
})
);
}
console.log(
`Posted batch: ${employee ? 1 : 0} employee + ${contractors.length} contractor(s) for ${dateKey}`
);
}
export const handler = async (event) => {
if (event.Records?.[0]?.eventSource === "aws:s3") {
for (const record of event.Records) {
await handleS3Event(record);
}
} else if (event.Records?.[0]?.eventSource === "aws:sqs") {
for (const record of event.Records) {
await handleSqsEvent(record);
}
} else {
console.log("Unknown event source:", JSON.stringify(event).slice(0, 200));
}
return { statusCode: 200 };
};

View file

@ -82,23 +82,6 @@ Resources:
SubnetId: !Ref PublicSubnet
RouteTableId: !Ref PublicRouteTable
PublicSubnetB:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref Vpc
CidrBlock: 10.20.3.0/24
AvailabilityZone: !Select [1, !GetAZs ""]
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: payments-dashboard-public-b
PublicSubnetBRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnetB
RouteTableId: !Ref PublicRouteTable
PrivateRouteTable:
Type: AWS::EC2::RouteTable
Properties:
@ -126,55 +109,6 @@ Resources:
- IpProtocol: "-1"
CidrIp: 0.0.0.0/0
# Aurora Serverless v2 PostgreSQL (Dataddo → payroll data)
AuroraSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Aurora PostgreSQL access
VpcId: !Ref Vpc
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 5432
ToPort: 5432
SourceSecurityGroupId: !Ref LambdaSecurityGroup
- IpProtocol: tcp
FromPort: 5432
ToPort: 5432
CidrIp: 0.0.0.0/0
AuroraSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupDescription: Public subnets for Aurora PostgreSQL
SubnetIds:
- !Ref PublicSubnet
- !Ref PublicSubnetB
AuroraCluster:
Type: AWS::RDS::DBCluster
Properties:
Engine: aurora-postgresql
EngineVersion: "16.4"
DatabaseName: payroll
MasterUsername: payroll_admin
ManageMasterUserPassword: true
ServerlessV2ScalingConfiguration:
MinCapacity: 0.5
MaxCapacity: 2
VpcSecurityGroupIds:
- !Ref AuroraSecurityGroup
DBSubnetGroupName: !Ref AuroraSubnetGroup
EnableHttpEndpoint: true
StorageEncrypted: true
AuroraInstance:
Type: AWS::RDS::DBInstance
Properties:
DBClusterIdentifier: !Ref AuroraCluster
DBInstanceClass: db.serverless
Engine: aurora-postgresql
PubliclyAccessible: true
PaymentsCsvBucket:
Type: AWS::S3::Bucket
Properties:
@ -195,6 +129,98 @@ Resources:
AttributeName: ttl
Enabled: true
# Payroll email ingestion (replaces Dataddo/Aurora pipeline)
PayrollEmailBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub seahaven-payroll-emails-${AWS::AccountId}
LifecycleConfiguration:
Rules:
- Id: ExpireEmails
Status: Enabled
ExpirationInDays: 30
PayrollEmailBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref PayrollEmailBucket
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: AllowSESPut
Effect: Allow
Principal:
Service: ses.amazonaws.com
Action: s3:PutObject
Resource: !Sub arn:aws:s3:::seahaven-payroll-emails-${AWS::AccountId}/*
Condition:
StringEquals:
AWS:SourceAccount: !Ref AWS::AccountId
PayrollEmailRule:
Type: AWS::SES::ReceiptRule
DependsOn: PayrollEmailBucketPolicy
Properties:
RuleSetName: INBOUND_MAIL
After: ExistingRuleSetWorkorderEmailRuleEA29F845-okepxcVarTfu
Rule:
Name: store-payroll-emails
Enabled: true
ScanEnabled: true
Recipients:
- payroll@int.seahaven.com
Actions:
- S3Action:
BucketName: !Ref PayrollEmailBucket
ObjectKeyPrefix: inbound/
ContractorBatchQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: payments-contractor-batch
DelaySeconds: 600
MessageRetentionPeriod: 86400
VisibilityTimeout: 60
ProcessPayrollEmailFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: payments-processPayrollEmail
Handler: src/processPayrollEmail.handler
Timeout: 60
Environment:
Variables:
SLACK_BOT_TOKEN_PARAM: /payments-dashboard/slack-bot-token
PAYROLL_CHANNEL_ID: C0AV5RBMYKU
CONTRACTOR_BATCH_QUEUE_URL: !Ref ContractorBatchQueue
Events:
EmailReceived:
Type: S3
Properties:
Bucket: !Ref PayrollEmailBucket
Events: s3:ObjectCreated:*
Filter:
S3Key:
Rules:
- Name: prefix
Value: inbound/
ContractorBatch:
Type: SQS
Properties:
Queue: !GetAtt ContractorBatchQueue.Arn
BatchSize: 1
Policies:
- S3ReadPolicy:
BucketName: !Sub seahaven-payroll-emails-${AWS::AccountId}
- DynamoDBCrudPolicy:
TableName: !Ref DashboardTable
- SSMParameterReadPolicy:
ParameterName: payments-dashboard/slack-bot-token
- SQSSendMessagePolicy:
QueueName: !GetAtt ContractorBatchQueue.QueueName
- SQSPollerPolicy:
QueueName: !GetAtt ContractorBatchQueue.QueueName
ProcessPaymentCsvFunction:
Type: AWS::Serverless::Function
Properties:
@ -330,51 +356,6 @@ Resources:
- ec2:DeleteNetworkInterface
Resource: "*"
NotifyPayrollFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: payments-notifyPayroll
Handler: src/notifyPayroll.handler
Timeout: 60
VpcConfig:
SubnetIds:
- !Ref PrivateSubnet
SecurityGroupIds:
- !Ref LambdaSecurityGroup
Environment:
Variables:
AURORA_CLUSTER_ARN: !GetAtt AuroraCluster.DBClusterArn
AURORA_SECRET_ARN: !GetAtt AuroraCluster.MasterUserSecret.SecretArn
SLACK_BOT_TOKEN_PARAM: /payments-dashboard/slack-bot-token
Events:
PayrollCheck:
Type: Schedule
Properties:
Schedule: cron(0 18 ? * MON-FRI *)
Description: Check for new payroll data at 2pm ET (18:00 UTC)
Enabled: true
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref DashboardTable
- SSMParameterReadPolicy:
ParameterName: payments-dashboard/slack-bot-token
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- ec2:CreateNetworkInterface
- ec2:DescribeNetworkInterfaces
- ec2:DeleteNetworkInterface
Resource: "*"
- Effect: Allow
Action:
- rds-data:ExecuteStatement
Resource: !GetAtt AuroraCluster.DBClusterArn
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource: !GetAtt AuroraCluster.MasterUserSecret.SecretArn
Outputs:
SlackEventUrl:
Description: URL to set as the Slack app Request URL
@ -385,12 +366,6 @@ Outputs:
StaticOutboundIp:
Description: Static IP for BoA API whitelist
Value: !Ref NatEip
AuroraEndpoint:
Description: Aurora PostgreSQL cluster endpoint
Value: !GetAtt AuroraCluster.Endpoint.Address
AuroraPort:
Description: Aurora PostgreSQL port
Value: !GetAtt AuroraCluster.Endpoint.Port
AuroraSecretArn:
Description: Secrets Manager ARN for Aurora master credentials
Value: !GetAtt AuroraCluster.MasterUserSecret.SecretArn
PayrollEmailBucket:
Description: S3 bucket for inbound payroll emails from SES
Value: !Ref PayrollEmailBucket