From b16c133a0d7146f7c940c9a5b5d0823889097e3a Mon Sep 17 00:00:00 2001 From: d-klotz Date: Wed, 28 Oct 2026 13:51:30 -0300 Subject: [PATCH] feat(freshbooks): add the FreshBooks API module (v1) Promote packages/needs-updating/freshbooks to packages/v1-ready/freshbooks as @friggframework/api-module-freshbooks on @friggframework/core ^2.0.0-next.126, ported from the module running in production in lefthookhq/freshbooks-integrations. - token requests (authorization_code and refresh_token) are sent once and never retried on 401/429/5xx/network errors; FreshBooks grants are single-use - entity keyed by business account_id with the business name in details - redirect_uri is ${REDIRECT_URI}/freshbooks - clients, invoices, payments, users/me and callback methods - nock transport tests, including core Module refresh/rotation flows Supersedes #156. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 159 +++- packages/needs-updating/freshbooks/api.js | 175 ---- .../freshbooks/defaultConfig.json | 11 - .../needs-updating/freshbooks/definition.js | 56 -- packages/needs-updating/freshbooks/index.js | 15 - .../needs-updating/freshbooks/jest-setup.js | 3 - .../freshbooks/jest-teardown.js | 2 - .../needs-updating/freshbooks/jest.config.js | 20 - packages/needs-updating/freshbooks/manager.js | 240 ------ .../freshbooks/mocks/getCodeFromToken.json | 9 - .../freshbooks/mocks/getUsersMe.json | 245 ------ .../freshbooks/models/IndividualUser.js | 66 -- .../freshbooks/models/credential.js | 23 - .../freshbooks/models/entity.js | 18 - packages/needs-updating/freshbooks/readme.md | 1 - .../freshbooks/tests/auther.test.js | 78 -- .../freshbooks/tests/manager.test.js | 73 -- packages/v1-ready/freshbooks/.env.example | 3 + .../freshbooks/CHANGELOG.md | 0 packages/v1-ready/freshbooks/LICENSE.md | 21 + packages/v1-ready/freshbooks/README.md | 64 ++ packages/v1-ready/freshbooks/api.js | 285 ++++++ .../v1-ready/freshbooks/defaultConfig.json | 9 + packages/v1-ready/freshbooks/definition.js | 81 ++ packages/v1-ready/freshbooks/index.js | 5 + packages/v1-ready/freshbooks/package.json | 32 + .../v1-ready/freshbooks/tests/api.test.js | 814 ++++++++++++++++++ .../freshbooks/tests/definition.test.js | 248 ++++++ .../v1-ready/freshbooks/tests/fixtures.js | 46 + .../v1-ready/freshbooks/tests/index.test.js | 9 + .../v1-ready/freshbooks/tests/module.test.js | 252 ++++++ 31 files changed, 2003 insertions(+), 1060 deletions(-) delete mode 100644 packages/needs-updating/freshbooks/api.js delete mode 100644 packages/needs-updating/freshbooks/defaultConfig.json delete mode 100644 packages/needs-updating/freshbooks/definition.js delete mode 100644 packages/needs-updating/freshbooks/index.js delete mode 100644 packages/needs-updating/freshbooks/jest-setup.js delete mode 100644 packages/needs-updating/freshbooks/jest-teardown.js delete mode 100644 packages/needs-updating/freshbooks/jest.config.js delete mode 100644 packages/needs-updating/freshbooks/manager.js delete mode 100644 packages/needs-updating/freshbooks/mocks/getCodeFromToken.json delete mode 100644 packages/needs-updating/freshbooks/mocks/getUsersMe.json delete mode 100644 packages/needs-updating/freshbooks/models/IndividualUser.js delete mode 100644 packages/needs-updating/freshbooks/models/credential.js delete mode 100644 packages/needs-updating/freshbooks/models/entity.js delete mode 100644 packages/needs-updating/freshbooks/readme.md delete mode 100644 packages/needs-updating/freshbooks/tests/auther.test.js delete mode 100644 packages/needs-updating/freshbooks/tests/manager.test.js create mode 100644 packages/v1-ready/freshbooks/.env.example rename packages/{needs-updating => v1-ready}/freshbooks/CHANGELOG.md (100%) create mode 100644 packages/v1-ready/freshbooks/LICENSE.md create mode 100644 packages/v1-ready/freshbooks/README.md create mode 100644 packages/v1-ready/freshbooks/api.js create mode 100644 packages/v1-ready/freshbooks/defaultConfig.json create mode 100644 packages/v1-ready/freshbooks/definition.js create mode 100644 packages/v1-ready/freshbooks/index.js create mode 100644 packages/v1-ready/freshbooks/package.json create mode 100644 packages/v1-ready/freshbooks/tests/api.test.js create mode 100644 packages/v1-ready/freshbooks/tests/definition.test.js create mode 100644 packages/v1-ready/freshbooks/tests/fixtures.js create mode 100644 packages/v1-ready/freshbooks/tests/index.test.js create mode 100644 packages/v1-ready/freshbooks/tests/module.test.js diff --git a/package-lock.json b/package-lock.json index c73a4a2e..f492ffd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7478,7 +7478,6 @@ "version": "3.1113.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-scheduler/-/client-scheduler-3.1113.0.tgz", "integrity": "sha512-1vdzaO+mSivNNQYTROPML7mSfto3g9cSv+2bIfsww5fzh3K8F15AEQ4fu0hrCDW9O5FrfKcbxQy0rdePmSXTHA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.977.8", @@ -7498,7 +7497,6 @@ "version": "3.977.8", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz", "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.974.4", @@ -7518,7 +7516,6 @@ "version": "3.972.69", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz", "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.977.8", @@ -7535,7 +7532,6 @@ "version": "3.972.71", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz", "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.977.8", @@ -7554,7 +7550,6 @@ "version": "3.973.14", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz", "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.977.8", @@ -7579,7 +7574,6 @@ "version": "3.972.80", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz", "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.69", @@ -7602,7 +7596,6 @@ "version": "3.972.69", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz", "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.977.8", @@ -7619,7 +7612,6 @@ "version": "3.973.13", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz", "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.977.8", @@ -7638,7 +7630,6 @@ "version": "3.972.75", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz", "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.977.8", @@ -7656,7 +7647,6 @@ "version": "3.1111.0", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz", "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.977.8", @@ -7674,7 +7664,6 @@ "version": "3.974.4", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz", "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -7688,7 +7677,6 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -7698,7 +7686,6 @@ "version": "3.33.2", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.2.tgz", "integrity": "sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.17.2", @@ -7712,7 +7699,6 @@ "version": "4.5.2", "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.33.2", @@ -7727,7 +7713,6 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.33.2", @@ -7742,7 +7727,6 @@ "version": "4.11.2", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.2.tgz", "integrity": "sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.33.2", @@ -7757,7 +7741,6 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.2.tgz", "integrity": "sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.33.2", @@ -7772,7 +7755,6 @@ "version": "4.17.2", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7785,7 +7767,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/@aws-sdk/client-secrets-manager": { @@ -15891,6 +15872,10 @@ "resolved": "packages/v1-ready/fireflies", "link": true }, + "node_modules/@friggframework/api-module-freshbooks": { + "resolved": "packages/v1-ready/freshbooks", + "link": true + }, "node_modules/@friggframework/api-module-frigg-scale-test": { "resolved": "packages/v1-ready/frigg-scale-test", "link": true @@ -22426,7 +22411,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -22444,7 +22428,6 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -22461,7 +22444,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/all-contributors-cli": { @@ -27236,7 +27218,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -27294,7 +27275,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "dev": true, "funding": [ { "type": "github", @@ -36319,7 +36299,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -45826,6 +45805,135 @@ "uuid": "dist/esm/bin/uuid" } }, + "packages/v1-ready/freshbooks": { + "name": "@friggframework/api-module-freshbooks", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@friggframework/core": "^2.0.0-next.126" + }, + "devDependencies": { + "@friggframework/prettier-config": "^1.2.2", + "jest": "^28.1.3", + "nock": "^13.5.4", + "prettier": "^2.7.1" + } + }, + "packages/v1-ready/freshbooks/node_modules/@friggframework/core": { + "version": "2.0.0-next.126", + "resolved": "https://registry.npmjs.org/@friggframework/core/-/core-2.0.0-next.126.tgz", + "integrity": "sha512-0WDGp/3LMMvyXDjxLd9gn7N1hqbQUppryC01m+dqjh+ELogqJxmfnhrETYrFHvIJUxyCbD6x8oe5AOxPRhgWpg==", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0", + "@aws-sdk/client-kms": "^3.588.0", + "@aws-sdk/client-lambda": "^3.714.0", + "@aws-sdk/client-s3": "^3.588.0", + "@aws-sdk/client-scheduler": "^3.588.0", + "@aws-sdk/client-sqs": "^3.588.0", + "@aws-sdk/client-ssm": "^3.588.0", + "@aws-sdk/s3-request-presigner": "^3.588.0", + "@friggframework/schemas": "2.0.0-next.126", + "@hapi/boom": "^10.0.1", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.9.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.220.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-metrics": "^2.9.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "bcryptjs": "^2.4.3", + "body-parser": "^1.20.5", + "bson": "^4.7.2", + "chalk": "^4.1.2", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.22.2", + "express-async-handler": "^1.2.0", + "form-data": "^4.0.6", + "fs-extra": "^11.2.0", + "lodash": "4.18.1", + "lodash.get": "^4.4.2", + "node-fetch": "^2.6.7", + "serverless-http": "^2.7.0", + "uuid": "^11.1.1" + }, + "peerDependencies": { + "@prisma/client": "^6.19.3", + "prisma": "^6.19.3" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "packages/v1-ready/freshbooks/node_modules/@friggframework/schemas": { + "version": "2.0.0-next.126", + "resolved": "https://registry.npmjs.org/@friggframework/schemas/-/schemas-2.0.0-next.126.tgz", + "integrity": "sha512-Ri5MrcJ6Ua5/sidA6HTYnZ9a5AKk0g48ZT4WSYBHFb3Kf/nX7US8LorO2f2cim36f91qjSsVfkwfwW6+T0uuTw==", + "license": "MIT", + "dependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^2.1.1" + } + }, + "packages/v1-ready/freshbooks/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "packages/v1-ready/freshbooks/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "packages/v1-ready/freshbooks/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "packages/v1-ready/freshbooks/node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "packages/v1-ready/freshbooks/node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "packages/v1-ready/frigg-scale-test": { "name": "@friggframework/api-module-frigg-scale-test", "version": "0.1.0", @@ -53496,6 +53604,7 @@ "eslint": "^8.22.0", "jest": "^28.1.3", "jest-environment-jsdom": "^28.1.3", + "nock": "^13.5.4", "prettier": "^2.7.1" } }, diff --git a/packages/needs-updating/freshbooks/api.js b/packages/needs-updating/freshbooks/api.js deleted file mode 100644 index dd06e09c..00000000 --- a/packages/needs-updating/freshbooks/api.js +++ /dev/null @@ -1,175 +0,0 @@ -const {OAuth2Requester} = require('@friggframework/core'); - -class Api extends OAuth2Requester { - - constructor(params) { - super(params); - this.access_token = params.access_token; - this.refresh_token = params.refresh_token; - this.accountId = params.accountId; - this.baseUrl = `https://api.freshbooks.com`; - this.URLs = { - createExpense: () => `${this.baseUrl}/accounting/account/${this.accountId}/expenses/expenses`, - listStaff: () => `${this.baseUrl}/accounting/account/${this.accountId}/users/staffs`, - listExpenseCategories: () => `${this.baseUrl}/accounting/account/${this.accountId}/expenses/categories`, - otherIncomes: () => `${this.baseUrl}/accounting/account/${this.accountId}/other_incomes/other_incomes`, - client: `/accounting/account/${this.accountId}/users/clients`, - token: '/auth/oauth/token', - user_info: `/auth/api/v1/users/me`, - }; - this.tokenUri = `${this.baseUrl}${this.URLs.token}`; - } - - getAuthUri(params) { - console.log('Freshbooks: getAuthUri', params, process.env.REDIRECT_URI); - const state = JSON.stringify({app: 'freshbooks'}); - const redirect_uri = params?.redirect_uri ?? process.env.REDIRECT_URI ?? ''; - return [ - 'https://my.freshbooks.com/service/auth/oauth/authorize?response_type=code', - `client_id=${this.client_id}`, - `state=${state}`, - `redirect_uri=${redirect_uri}`, - ].join('&'); - } - - async addAuthHeaders(headers) { - const baseHeaders = await super.addAuthHeaders(headers); - baseHeaders['Api-Version'] = 'alpha'; - return baseHeaders; - } - - setAccountId(accountId) { - this.accountId = accountId; - } - - setAccessToken(access_token) { - this.access_token = access_token; - } - - setRefreshToken(refresh_token) { - this.refresh_token = refresh_token; - } - - async getUserInfo() { - try { - const res = await this._get({ - url: this.baseUrl + this.URLs.user_info, - }); - const identity = { - id: res.response.id, - user_name: - res.response.first_name + ' ' + res.response.last_name, - user_email: res.response.email, - }; - - return identity; - } catch (e) { - console.log('Get User Info error:', e.message); - throw e; - } - } - - async getUserEmail() { - try { - const res = await this._get({ - url: this.baseUrl + this.URLs.user_info, - }); - return res.response.email; - } catch (e) { - console.log('Get User Info error:', e.message); - throw e; - } - } - - async retrieveAccounts() { - const res = await this._get({ - url: this.baseUrl + this.URLs.user_info, - }); - - let memberships = res.response.business_memberships; - let accounts = memberships.map( - (mem) => { - return { - name: mem.business.name, - id: mem.business.account_id, - // country: mem.business.address.country - }; - } - ); - - return accounts; - } - - async createOtherIncome(otherIncome) { - const body = { - other_income: otherIncome, - }; - - return await this._post({ - body, - url: this.URLs.otherIncomes(), - }); - } - - async createExpense(input) { - const date = [ - String(input.date.getFullYear()), - String(input.date.getMonth() + 1).padStart(2, '0'), - String(input.date.getDate() + 1).padStart(2, '0'), - ].join('-'); - const body = { - expense: { - amount: { - amount: String(input.amount), - code: input.code, - }, - categoryid: input.categoryid, - staffid: input.staffid, - date, - notes: input.description, - }, - }; - - return this._post({ - body, - url: this.URLs.createExpense(), - }); - } - - async listExpenseCategories(params) { - const query = params?.name ? {query: {'search[name]': params.name}} : {}; - const options = { - method: 'GET', - url: this.URLs.listExpenseCategories(), - headers: await this.addAuthHeaders({}), - ...query, - }; - console.log('listExpenseCategories options:', options); - return this._get(options); - } - - async listStaff() { - const options = { - method: 'GET', - url: this.URLs.listStaff(), - headers: await this.addAuthHeaders({}), - }; - console.log('listStaff options:', options); - return this._get(options); - } - - async updateOtherIncome(otherIncome, id) { - const body = { - other_income: otherIncome, - }; - - const url = this.URLs.otherIncomes() + '/' + id; - - return await this._put({ - body, - url, - }); - } -} - -module.exports = {Api} diff --git a/packages/needs-updating/freshbooks/defaultConfig.json b/packages/needs-updating/freshbooks/defaultConfig.json deleted file mode 100644 index d4f2c969..00000000 --- a/packages/needs-updating/freshbooks/defaultConfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "freshbooks", - "label": "FreshBooks", - "productUrl": "https://freshbooks.com", - "apiDocs": "https://www.freshbooks.com/api/start", - "logoUrl": "https://friggframework.org/assets/img/freshbooks-icon.png", - "categories": [ - "Finance" - ], - "description": "FreshBooks is cloud-based invoice and accounting software for small and midsize businesses. It is an easy-to-use solution for creating invoices, organizing expenses, running accounting reports, and getting paid." -} \ No newline at end of file diff --git a/packages/needs-updating/freshbooks/definition.js b/packages/needs-updating/freshbooks/definition.js deleted file mode 100644 index 8e2d5da8..00000000 --- a/packages/needs-updating/freshbooks/definition.js +++ /dev/null @@ -1,56 +0,0 @@ -require('dotenv').config(); -const {Api} = require('./api'); -const {get} = require("@friggframework/core"); -const config = require('./defaultConfig.json') - -const Definition = { - API: Api, - getName: function () { - return config.name - }, - moduleName: config.name,//maybe not required, - requiredAuthMethods: { - getToken: async function (api, params) { - const code = get(params.data, 'code'); - return api.getTokenFromCode(code); - }, - getEntityDetails: async function (api, callbackParams, tokenResponse) { - const accountId = String(callbackParams.data.account_id || callbackParams.data.appOrgId) - this.accountId = accountId; - return { - identifiers: { - externalId: accountId, - subType: callbackParams.data.subType - }, - details: {user: this.userId}, - } - }, - getCredentialDetails: async function (api) { - const userDetails = await api.getUserInfo(); - return { - identifiers: {externalId: userDetails.identifier}, - details: {appUserId: userDetails.id} - }; - }, - apiPropertiesToPersist: { - credential: ['access_token', 'refresh_token'], - entity: ['accountId', 'subType'] - }, - testAuthRequest: async function (api) { - return await api.getUserInfo() - }, - getAuthorizationRequirements(params) { - return { - url: this.api.getAuthUri(params), - type: 'oauth2', - }; - } - }, - env: { - client_id: process.env.FRESHBOOKS_CLIENT_ID, - client_secret: process.env.FRESHBOOKS_CLIENT_SECRET, - redirect_uri: `${process.env.REDIRECT_URI}/freshbooks`, - } -}; - -module.exports = {Definition}; diff --git a/packages/needs-updating/freshbooks/index.js b/packages/needs-updating/freshbooks/index.js deleted file mode 100644 index a915b0e3..00000000 --- a/packages/needs-updating/freshbooks/index.js +++ /dev/null @@ -1,15 +0,0 @@ -const {Api} = require('./api'); -const {Credential} = require('./models/credential'); -const {Entity} = require('./models/entity'); -const Manager = require('./manager'); -const Config = require('./defaultConfig'); -const {Definition} = require('./definition'); - -module.exports = { - Api, - Credential, - Entity, - Config, - Manager, - Definition, -}; diff --git a/packages/needs-updating/freshbooks/jest-setup.js b/packages/needs-updating/freshbooks/jest-setup.js deleted file mode 100644 index 9d3161da..00000000 --- a/packages/needs-updating/freshbooks/jest-setup.js +++ /dev/null @@ -1,3 +0,0 @@ -const {globalSetup} = require('@friggframework/test'); -require('dotenv').config(); -module.exports = globalSetup; diff --git a/packages/needs-updating/freshbooks/jest-teardown.js b/packages/needs-updating/freshbooks/jest-teardown.js deleted file mode 100644 index 5bc72510..00000000 --- a/packages/needs-updating/freshbooks/jest-teardown.js +++ /dev/null @@ -1,2 +0,0 @@ -const {globalTeardown} = require('@friggframework/test'); -module.exports = globalTeardown; diff --git a/packages/needs-updating/freshbooks/jest.config.js b/packages/needs-updating/freshbooks/jest.config.js deleted file mode 100644 index 48f9fcc5..00000000 --- a/packages/needs-updating/freshbooks/jest.config.js +++ /dev/null @@ -1,20 +0,0 @@ -/* - * For a detailed explanation regarding each configuration property, visit: - * https://jestjs.io/docs/configuration - */ -module.exports = { - // preset: '@friggframework/test-environment', - coverageThreshold: { - global: { - statements: 13, - branches: 0, - functions: 1, - lines: 13, - }, - }, - // A path to a module which exports an async function that is triggered once before all test suites - globalSetup: './jest-setup.js', - - // A path to a module which exports an async function that is triggered once after all test suites - globalTeardown: './jest-teardown.js', -}; diff --git a/packages/needs-updating/freshbooks/manager.js b/packages/needs-updating/freshbooks/manager.js deleted file mode 100644 index a1fa2ebf..00000000 --- a/packages/needs-updating/freshbooks/manager.js +++ /dev/null @@ -1,240 +0,0 @@ -const {ModuleManager, get, debug, flushDebugLog} = require('@friggframework/core'); -const {Entity} = require('./models/entity'); -const {Credential} = require('./models/credential'); -const {IndividualUser} = require('./models/IndividualUser'); -const {Api} = require('./api'); -const config = require('./defaultConfig.json'); - -class Manager extends ModuleManager { - static Entity = Entity; - static Credential = Credential; - - constructor(params) { - super(params); - this.api = new Api({}); - this.credential = null; - this.entity = null; - } - - //------------------------------------------------------------ - // Required methods - static getName() { - return config.name; - } - - static async getInstance(params) { - const instance = new this(params); - // All async code here - - // initializes the Api - let credential, entity; - const apiParams = { - delegate: instance, - client_id: process.env.FRESHBOOKS_CLIENT_ID, - client_secret: process.env.FRESHBOOKS_CLIENT_SECRET, - redirect_uri: `${process.env.REDIRECT_URI}/freshbooks`, - }; - - if (params.entityId) { - entity = await Entity.findOne({_id: params.entityId}); - if (!entity) - throw new Error( - `Freshbooks Module: getInstance: No entity found for id: ${params.entityId}` - ); - - credential = await Credential.findOne({_id: entity.credential}); - } - instance.api = new Api(apiParams); - if (entity) { - instance.setEntity(entity); - } - if (credential) { - instance.setCredential(credential); - } - - return instance; - } - - async testAuth() { - let validAuth = false; - try { - if (await this.api.getUserInfo()) validAuth = true; - } catch (e) { - flushDebugLog(e); - } - return validAuth; - } - - getAuthorizationRequirements(params) { - return { - url: this.api.getAuthUri(params), - type: 'oauth2', - }; - } - - async processAuthorizationCallback(params) { - console.log('processAuthorizationCallback', JSON.stringify(params)); - const code = get(params.data, 'code'); - if (!code) throw new Error('Node valid params.data.code'); - - await this.getAccessToken(code); - - await this.findOrCreateEntity({ - externalId: String(params.data.account_id || params.data.appOrgId), - subType: params.data.subType, - }); - - if (!this.credential) { - throw new Error( - `Freshbooks Module: processAuthorizationCallback: No credential set.` - ); - } - - return { - entity_id: this.entity.id, - credential_id: this.credential.id, - type: Manager.getName(), - }; - } - - async findOrCreateEntity(data) { - const {externalId, subType} = data; - - const search = await Entity.find({ - externalId, - subType, - }); - if (search.length === 0) { - // validate choices!!! - // create entity - if (!this.credential) { - throw new Error( - `Freshbooks Module: No credential set when creating entity for externalId: ${externalId}` - ); - } - - const createObj = { - credential: this.credential.id, - user: this.userId, - externalId, - subType, - }; - this.setEntity(await Entity.create(createObj)); - } else if (search.length === 1) { - this.setEntity(search[0]); - } else { - debug( - 'Multiple entities found with the same externalId:', - externalId - ); - throw new Error( - `Multiple entities found with the same externalId: ${externalId}` - ); - } - return this.entity; - } - - //------------------------------------------------------------ - - async deauthorize() { - // wipe api connection - this.api = new Api({}); - - // delete credentials from the database - const entity = await Entity.findByUserId(this.userId); - if (entity.credential) { - await Credential.delete(entity.credential); - entity.credential = undefined; - await entity.save(); - } - this.credential = null; - } - - setEntity(entity) { - this.entity = entity; - this.api.setAccountId(entity.externalId); - } - - setCredential(credential) { - this.credential = credential; - this.api.setAccessToken(credential.access_token); - this.api.setRefreshToken(credential.refresh_token); - } - - async getAccessToken(code) { - return this.api.getTokenFromCode(code); - } - - async receiveNotification(notifier, delegateString, object = null) { - if (notifier instanceof Api) { - if (delegateString === this.api.DLGT_TOKEN_UPDATE) { - debug(`should update the token: ${object}`); - - const userDetails = await this.api.getUserInfo(); - console.log('userDetails', userDetails); - - const updatedToken = { - externalId: userDetails.id, - appUserId: userDetails.id, - access_token: this.api.access_token, - refresh_token: this.api.refresh_token, - expires_at: String(this.api.accessTokenExpire), - // portalId: userDetails.portalId, - auth_is_valid: true, - }; - - if (!this.credential) { - console.log('Credential not found, searching by external ID', userDetails.id); - const credentialSearch = await Credential.find({ - externalId: userDetails.id, - }); - if (credentialSearch.length === 0) { - let user = await IndividualUser.getUserByAppUserId( - updatedToken.appUserId - ); - if (!user) { - user = await IndividualUser.create({ - name: userDetails.user_name, - email: userDetails.user_email, - externalId: userDetails.id, - appUserId: updatedToken.appUserId, - organizationUser: null, - }); - } - - this.credential = await Credential.create({ - ...updatedToken, - user: user._id, - }); - } else if (credentialSearch.length === 1) { - console.log('Credential user', credentialSearch[0].user); - console.log('Updating credential', credentialSearch[0]._id); - this.credential = await Credential.update( - {_id: credentialSearch[0]._id}, - updatedToken - ); - } else { - // Handling multiple credentials found with an error for the time being - console.log( - 'Multiple credentials found with the same external ID:', - updatedToken.externalId, - ); - } - } else { - this.credential = await Credential.update( - this.credential, - updatedToken - ); - } - } - if (delegateString === this.api.DLGT_TOKEN_DEAUTHORIZED) { - await this.deauthorize(); - } - if (delegateString === this.api.DLGT_INVALID_AUTH) { - await this.markCredentialsInvalid(); - } - } - } -} - -module.exports = Manager; diff --git a/packages/needs-updating/freshbooks/mocks/getCodeFromToken.json b/packages/needs-updating/freshbooks/mocks/getCodeFromToken.json deleted file mode 100644 index b8dafa06..00000000 --- a/packages/needs-updating/freshbooks/mocks/getCodeFromToken.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "access_token": "", - "token_type": "Bearer", - "expires_in": 43200, - "refresh_token": "", - "scope": "user:profile:read user:estimates:read", - "created_at": 1673065243, - "direct_buy_tokens": [] -} \ No newline at end of file diff --git a/packages/needs-updating/freshbooks/mocks/getUsersMe.json b/packages/needs-updating/freshbooks/mocks/getUsersMe.json deleted file mode 100644 index f35ef1d7..00000000 --- a/packages/needs-updating/freshbooks/mocks/getUsersMe.json +++ /dev/null @@ -1,245 +0,0 @@ -{ - "response": { - "id": 11329208, - "identity_id": 11329208, - "identity_uuid": "02d8af3a-0712-40fb-8eb3-924727f8fd76", - "first_name": "Aden", - "last_name": "Forshaw", - "email": "aden@thatapicompany.com", - "language": "en", - "confirmed_at": null, - "created_at": "2022-12-26T03:06:28Z", - "unconfirmed_email": null, - "setup_complete": true, - "phone_numbers": [ - { - "title": "", - "phone_number": null - } - ], - "addresses": [ - null - ], - "profession": null, - "links": { - "me": "/service/auth/api/v1/users?id=11329208", - "roles": "/service/auth/api/v1/users/role/11329208" - }, - "profile": { - "setup_complete": true, - "first_name": "Aden", - "last_name": "Forshaw", - "phone_number": null, - "address": null, - "professions": [], - "has_password": true, - "is_email_confirmed": true, - "timezone": "Australia/Sydney" - }, - "permissions": { - "OdKk8r": { - "staff.limit": -1, - "client.limit": -1, - "retainers.limit": -1, - "attachments.access": true, - "bacs_fee_cap.limit": 400, - "emails_page.access": true, - "sepa_fee_cap.limit": 1000, - "rich_proposals.access": true, - "accounts_payable.access": true, - "retainers_feature.access": true, - "business_accountant.limit": 10, - "documents_ocr_plan.access": true, - "advanced_accounting.access": true, - "proposals_candidate.access": true, - "project_profitability.access": true, - "documents_ocr_bill_plan.access": true, - "project_service_estimates.access": true, - "documents_ocr_complete_plan.access": true, - "estimate_convert_to_project.access": true, - "flexcoa_advanced_accounting.access": true, - "BetaHeliosAsyncExpenses.access": true, - "beta_mobile_create_expense_subcategory.access": true, - "ios_beta_zendesk_widget.access": true, - "mobile_receipt_rebilling.access": true, - "helios_pushnotifications.beta.access": true, - "ios_beta_payment_schedules.access": true, - "helios_rebill_time.access": true, - "helios_dashboard.access": true, - "helios_late_fee_reminder.beta.access": true, - "helios_bulk_actions_invoices.beta.access": true, - "auto_bank_import.access": true, - "helios_virtual_terminal.beta.access": true, - "helios_expense_rebilling.beta.access": true, - "helios_company_taxes.beta.access": true, - "helios_invoice_archive.beta.access": true, - "helios_sync_throttle.beta.access": true, - "BankReconciliation.access": true, - "helios_push_resource_to_use_execute.beta.access": true, - "bank_rec_smart_match.access": true, - "helios_remote_search.beta.access": true, - "helios_virtual_terminal_tutorial.beta.access": true, - "stripe_advanced_payments.access": true, - "helios_virtual_terminal_advertising.beta.access": true, - "helios_stripe_virtual_terminal.beta.access": true, - "plaid_integration.access": true, - "stripe_payments_intent.access": true, - "cardapp_new_payment_methods.access": true, - "project_dashboard_revamp.access": true, - "stripe_ach.access": true, - "rounded_time_tracking.access": true, - "shortly.access": true, - "fiscal_year.access": true, - "new_payments_page": true, - "bank_import_failure_notifications.access": true, - "payments_withdrawal_report.access": true, - "time_entry_billed_amount.access": true, - "paypal_gateway.access": true, - "partial_payments.access": true, - "payment_links.access": true, - "bacs_direct_debit.access": true, - "saltedge_integration.access": true, - "recurring_ach.access": true, - "sepa_direct_debit.access": true, - "andromeda_mileage_tracker.dev.access": true, - "sepa_direct_debit_mandate.access": true, - "bacs_direct_debit_mandate.access": true, - "inventory_mvp.access": true, - "core_data_from_es.access": true, - "refunds.access": true, - "scopes_ui_phase1.access": true, - "invoice_pdf_email.access": true, - "billable_items_v3.access": true, - "recurring_paypal.access": true, - "bill_payment_reconciliation.access": true, - "save_emails.access": true, - "plan_receipt_redesign.access": true, - "plan_receipt_yearly_pricing.access": true, - "accounts_payable_beta.access": true, - "accounts_payable_reports.access": true, - "project_line_items.access": true, - "outstanding_invoices_summary.access": true, - "email_customization_communication.access": true, - "ted_report_refactor.access": true, - "credit_reconciliation.access": true, - "acss_direct_debit.access": true, - "user_created_bank_transactions.access": true, - "global_settings.access": true, - "client_portal_monarch_updates.access": true, - "sudo_monarch_dashboard_widgets.access": true, - "global_settings_logo_and_theme.access": true, - "clients_advanced_search.access": true, - "payments_advanced_search.access": true, - "estimates_advanced_search.access": true, - "recurring_revenue_monarch_updates.access": true, - "project_widget_updates.access": true, - "project_duplication.access": true, - "credit_notes_payment_method.access": true, - "business_late_reminder.access": true, - "documents_ocr.access": true, - "time_zone_enhancement.access": true, - "enhanced_project_client_typeahead.access": true, - "documents_ocr_bill.access": true, - "documents_ocr_bill_complete.access": true, - "solvvy_support_chat.access": true, - "paypal_tiered_pricing.access": true, - "documents_ocr_phase1_ff.access": true, - "responsive_website.access": true, - "helios_manual_mileage_tracking.access": true, - "helios_mileage_tracking_improvements.access": true, - "expenses_bulk_export_beta.access": true, - "direct_buy_package_preselection.access": true, - "received_invoices_advanced_search.access": true, - "sales_managed_receipt.access": true, - "bank_reconciliation_v2.access": true, - "invitations_refactor.access": true, - "display_credits_plan_summary.access": true, - "metapane_payment_methods_improvement.access": true, - "bank_reconciliation_csv_upload.access": true, - "customer_support_page.access": true, - "improved_bank_transfer_education_for_clients.access": true, - "andromeda_manual_mileage_tracking.access": true, - "mtd_enhancements.access": true, - "premium_contractor_role.access": true, - "launchpad_v2.access": true, - "launchpad_trades.access": true, - "wepay_kyc_redirect.access": true, - "billing_upgrade_flow_v2.access": true, - "new_setup_quiz.access": true, - "billing_upgrade_flow_v3.access": true, - "launchpad_cards_version.access": true, - "flexible_chart_of_accounts.access": true, - "flexcoa_beta_rollout.access": true, - "hide_recently_updated.access": true, - "es_migrations.access": true - } - }, - "groups": [ - { - "id": 31350630, - "group_id": 23246529, - "role": "owner", - "identity_id": 11329208, - "identity_uuid": "02d8af3a-0712-40fb-8eb3-924727f8fd76", - "business_id": 11749407, - "active": true - } - ], - "subscription_statuses": { - "OdKk8r": "active_trial" - }, - "business_statuses": { - "OdKk8r": "active_trial" - }, - "integrations": {}, - "business_memberships": [ - { - "id": 31350630, - "role": "owner", - "unacknowledged_change": false, - "fasttrack_token": "eyJhbGciOiJIUzI1NiJ9.eyJmYXN0dHJhY2tfaWRlbnRpdHlfaWQiOiIxMTMyOTIwOCIsImZhc3R0cmFja19zeXN0ZW1faWQiOiI4MDMyMDc3IiwiZmFzdHRyYWNrX2J1c2luZXNzX2lkIjoiMTE3NDk0MDciLCJjcmVhdGVkX2F0IjoiMjAyMy0wMS0wN1QwNTowNDozMCswMDowMCJ9.sX66MUMbplNH9ancQ6lmWOEuUmlcqtQhV2O73maQqqM", - "business": { - "id": 11749407, - "business_uuid": "5c185846-3fcf-4abd-ba48-50aada5a0856", - "name": "ThatAPICompany", - "account_id": "OdKk8r", - "date_format": "mm/dd/yyyy", - "first_day_of_week": 6, - "active": true, - "timezone": "Australia/Sydney", - "status": "active_trial", - "advanced_accounting_enabled": false, - "address": { - "id": 13258274, - "street": "", - "street2": "", - "city": "", - "province": "", - "country": "United States", - "postal_code": "" - }, - "phone_number": { - "id": 4165447, - "phone_number": "(302) 303-5538" - }, - "business_clients": [] - } - } - ], - "identity_origin": null, - "timezone": "Australia/Sydney", - "roles": [ - { - "id": 12482174, - "role": "admin", - "systemid": 8032077, - "userid": 1, - "created_at": "2022-12-26T03:06:28Z", - "links": { - "destroy": "/service/auth/api/v1/users/role/12482174" - }, - "accountid": "OdKk8r" - } - ] - } -} \ No newline at end of file diff --git a/packages/needs-updating/freshbooks/models/IndividualUser.js b/packages/needs-updating/freshbooks/models/IndividualUser.js deleted file mode 100644 index aa2fcab1..00000000 --- a/packages/needs-updating/freshbooks/models/IndividualUser.js +++ /dev/null @@ -1,66 +0,0 @@ -const {mongoose} = require('@friggframework/core'); -const {Schema, model, models} = mongoose; -const schema1 = new Schema({}, {timestamps: true}) - -const User = models.User || model('User', schema1) - -const schema = new Schema({ - appUserId: {type: String, unique: true}, - name: {type: String}, - email: {type: String}, - organizationUser: {type: Schema.Types.ObjectId, ref: 'User'}, -}); - -schema.static({ - //decimals: 10, - /*update: async function (id, options) { - if ('password' in options) { - options.hashword = await bcrypt.hashSync( - options.password, - parseInt(this.decimals) - ); - delete options.password; - } - return this.findOneAndUpdate( - {_id: id}, - options, - {new: true, useFindAndModify: true} - ); - },*/ - getUserByUsername: async function (username) { - let getByUser; - try { - getByUser = await this.find({username}); - } catch (e) { - console.log('oops'); - } - - if (getByUser.length > 1) { - throw new Error( - 'Unique username or email? Please reach out to our developers' - ); - } - - if (getByUser.length === 1) { - return getByUser[0]; - } - }, - getUserByAppUserId: async function (appUserId) { - const getByUser = await this.find({appUserId}); - - if (getByUser.length > 1) { - throw new Error( - 'Supposedly using a unique appUserId? Please reach out to our developers' - ); - } - - if (getByUser.length === 1) { - return getByUser[0]; - } - }, -}); - -const IndividualUser = - User.discriminators?.IndividualUser || - User.discriminator('IndividualUser', schema); -module.exports = {IndividualUser}; diff --git a/packages/needs-updating/freshbooks/models/credential.js b/packages/needs-updating/freshbooks/models/credential.js deleted file mode 100644 index 027dc4fa..00000000 --- a/packages/needs-updating/freshbooks/models/credential.js +++ /dev/null @@ -1,23 +0,0 @@ -const {Credential: Parent, mongoose} = require('@friggframework/core'); -const {Schema} = mongoose; - -const schema = new Schema({ - access_token: { - type: String, - trim: true, - lhEncrypt: true, - }, - refresh_token: { - type: String, - trim: true, - lhEncrypt: true, - }, - access_token_expire: {type: Date}, - expires_at: {type: Date}, - externalId: {type: String}, -}); - -const name = 'FreshbooksCredential'; -const Credential = - Parent.discriminators?.[name] || Parent.discriminator(name, schema); -module.exports = {Credential}; diff --git a/packages/needs-updating/freshbooks/models/entity.js b/packages/needs-updating/freshbooks/models/entity.js deleted file mode 100644 index 672b41dd..00000000 --- a/packages/needs-updating/freshbooks/models/entity.js +++ /dev/null @@ -1,18 +0,0 @@ -const {Entity: Parent, mongoose} = require('@friggframework/core'); -const {Schema} = mongoose; - -const schema = new Schema({ - account_id: { - type: String, - }, - externalId: { - type: String, - }, - title: { - type: String, - } -}); -const name = 'FreshBookEntity'; -const Entity = - Parent.discriminators?.[name] || Parent.discriminator(name, schema); -module.exports = {Entity}; diff --git a/packages/needs-updating/freshbooks/readme.md b/packages/needs-updating/freshbooks/readme.md deleted file mode 100644 index 5d3dbe87..00000000 --- a/packages/needs-updating/freshbooks/readme.md +++ /dev/null @@ -1 +0,0 @@ -## FreshBooks Frigg Module \ No newline at end of file diff --git a/packages/needs-updating/freshbooks/tests/auther.test.js b/packages/needs-updating/freshbooks/tests/auther.test.js deleted file mode 100644 index 4df92277..00000000 --- a/packages/needs-updating/freshbooks/tests/auther.test.js +++ /dev/null @@ -1,78 +0,0 @@ -const {connectToDatabase, disconnectFromDatabase, createObjectId, Auther} = require('@friggframework/core'); -//require('dotenv').config(); -const {Definition} = require('../definition'); -const {Authenticator} = require('@friggframework/devtools'); -describe('Freshbooks Auther Tests', () => { - let manager, authUrl; - beforeAll(async () => { - await connectToDatabase(); - manager = await Auther.getInstance({ - definition: Definition, - userId: createObjectId(), - }); - }); - - afterAll(async () => { - await manager.CredentialModel.deleteMany(); - await manager.EntityModel.deleteMany(); - await disconnectFromDatabase(); - }); - - describe('getAuthorizationRequirements() test', () => { - it('should return auth requirements', async () => { - const requirements = manager.getAuthorizationRequirements({redirect_uri: manager.api.redirect_uri}); - expect(requirements).toBeDefined(); - expect(requirements.type).toEqual('oauth2'); - expect(requirements.url).toBeDefined(); - authUrl = encodeURI(requirements.url); - }); - }); - - describe('Authorization requests', () => { - let firstRes; - it('processAuthorizationCallback()', async () => { - const response = await Authenticator.oauth2(authUrl); - firstRes = await manager.processAuthorizationCallback({ - data: { - code: response.data.code, - }, - }); - expect(firstRes).toBeDefined(); - expect(firstRes.entity_id).toBeDefined(); - expect(firstRes.credential_id).toBeDefined(); - }); - it.skip('retrieves existing entity on subsequent calls', async () => { - const response = await Authenticator.oauth2(authUrl); - const res = await manager.processAuthorizationCallback({ - data: { - code: response.data.code, - }, - }); - expect(res).toEqual(firstRes); - }); - }); - describe('Test credential retrieval and manager instantiation', () => { - it('retrieve by entity id', async () => { - const newManager = await Auther.getInstance({ - userId: manager.userId, - entityId: manager.entity.id, - definition: Definition, - }); - expect(newManager).toBeDefined(); - expect(newManager.entity).toBeDefined(); - expect(newManager.credential).toBeDefined(); - expect(await newManager.testAuth()).toBeTruthy(); - }); - - it('retrieve by credential id', async () => { - const newManager = await Auther.getInstance({ - userId: manager.userId, - credentialId: manager.credential.id, - definition: Definition, - }); - expect(newManager).toBeDefined(); - expect(newManager.credential).toBeDefined(); - expect(await newManager.testAuth()).toBeTruthy(); - }); - }); -}); diff --git a/packages/needs-updating/freshbooks/tests/manager.test.js b/packages/needs-updating/freshbooks/tests/manager.test.js deleted file mode 100644 index 713b48e3..00000000 --- a/packages/needs-updating/freshbooks/tests/manager.test.js +++ /dev/null @@ -1,73 +0,0 @@ -const {Authenticator, connectToDatabase, disconnectFromDatabase, createObjectId} = require('@friggframework/core'); -require('dotenv').config(); -const Manager = require('../manager'); // Manager = require('../manager'); -const config = require('../defaultConfig.json'); - -describe(`Should fully test the ${config.label} Manager`, () => { - let manager, userManager; - - beforeAll(async () => { - await connectToDatabase(); - manager = await Manager.getInstance({ - userId: createObjectId(), - }); - }); - - afterAll(async () => { - await Manager.Credential.deleteMany(); - await Manager.Entity.deleteMany(); - await disconnectFromDatabase(); - }); - - it('getAuthorizationRequirements() should return auth requirements', async () => { - const requirements = await manager.getAuthorizationRequirements({redirect_uri: manager.api.redirect_uri}); - expect(requirements).exists; - expect(requirements.type).toBe('oauth2'); - authUrl = encodeURI(requirements.url); - }); - describe('processAuthorizationCallback()', () => { - it('should return auth details', async () => { - const response = await Authenticator.oauth2(authUrl); - const baseArr = response.base.split('/'); - response.entityType = baseArr[baseArr.length - 1]; - delete response.base; - - const authRes = await manager.processAuthorizationCallback({ - data: { - code: response.data.code, - }, - }); - expect(authRes).toBeDefined(); - expect(authRes).toHaveProperty('entity_id'); - expect(authRes).toHaveProperty('credential_id'); - expect(authRes).toHaveProperty('type'); - }); - it('should refresh token', async () => { - manager.api.access_token = 'nope'; - await manager.testAuth(); - expect(manager.api.access_token).not.toEqual('nope'); - expect(manager.api.access_token).toBeDefined(); - }); - it('should refresh token after a fresh database retrieval', async () => { - const newManager = await Manager.getInstance({ - userId: manager.userId, - entityId: manager.entity.id, - }); - newManager.api.access_token = 'nope'; - await newManager.testAuth(); - expect(manager.api.access_token).not.toEqual('nope'); - expect(manager.api.access_token).toBeDefined(); - }); - it('should error if incorrect auth data', async () => { - try { - await manager.processAuthorizationCallback({ - data: { - code: 'bad', - }, - }); - } catch (e) { - expect(e.message).toContain('400 BAD REQUEST'); - } - }); - }); -}); diff --git a/packages/v1-ready/freshbooks/.env.example b/packages/v1-ready/freshbooks/.env.example new file mode 100644 index 00000000..7ef72483 --- /dev/null +++ b/packages/v1-ready/freshbooks/.env.example @@ -0,0 +1,3 @@ +FRESHBOOKS_CLIENT_ID= +FRESHBOOKS_CLIENT_SECRET= +REDIRECT_URI=http://localhost:3000/redirect diff --git a/packages/needs-updating/freshbooks/CHANGELOG.md b/packages/v1-ready/freshbooks/CHANGELOG.md similarity index 100% rename from packages/needs-updating/freshbooks/CHANGELOG.md rename to packages/v1-ready/freshbooks/CHANGELOG.md diff --git a/packages/v1-ready/freshbooks/LICENSE.md b/packages/v1-ready/freshbooks/LICENSE.md new file mode 100644 index 00000000..c307ce69 --- /dev/null +++ b/packages/v1-ready/freshbooks/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Left Hook + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/v1-ready/freshbooks/README.md b/packages/v1-ready/freshbooks/README.md new file mode 100644 index 00000000..2127a9cb --- /dev/null +++ b/packages/v1-ready/freshbooks/README.md @@ -0,0 +1,64 @@ +# FreshBooks API Module (`@friggframework/api-module-freshbooks`) + +A [Frigg](https://friggframework.org) API module for the [FreshBooks API](https://www.freshbooks.com/api/start): OAuth 2.0, identity, clients, invoices, payments and webhook callbacks. + +```javascript +const { Definition } = require('@friggframework/api-module-freshbooks'); + +const { clients, pages } = await this.freshbooks.api.listClients(accountId); +``` + +## Environment + +| Variable | Purpose | +| -------------------------- | --------------------------------------------------------------- | +| `FRESHBOOKS_CLIENT_ID` | OAuth client id | +| `FRESHBOOKS_CLIENT_SECRET` | OAuth client secret | +| `REDIRECT_URI` | Base redirect URI; the module uses `${REDIRECT_URI}/freshbooks` | + +Scopes are configured on the FreshBooks app, not sent on the authorize URL. + +## Authentication + +- Authorize: `https://auth.freshbooks.com/oauth/authorize/` +- Token: `POST https://api.freshbooks.com/auth/oauth/token` with a JSON body +- Every request sends `Api-Version: alpha`. +- **Authorization codes and refresh tokens are single-use, and refresh tokens rotate.** Token requests are sent exactly once and never retried, even on a 401, 429, 5xx or network error; a 401 on a token request does not start a refresh. A retry after a lost 2xx would re-send a grant FreshBooks already consumed and invalidate the connection. Non-token requests keep core's backoff retries. + +## Entity and credential + +- **Entity** is keyed by the FreshBooks business `account_id` (`identifiers.externalId`), with the business name in `details.name`. A user with several businesses must pass `account_id` on the auth callback; the module throws with the choices instead of picking one. +- **Credential** is keyed by the FreshBooks user id and persists `access_token`, `refresh_token` and `freshbooksUserId`, so a refresh does not need a `users/me` call. + +## API methods + +Every accounting method takes the business `accountId` first. + +| Method | HTTP | +| --------------------------------------------------- | ------------------------------------------------------------ | +| `getUserDetails()` | `GET /auth/api/v1/users/me` | +| `listClients(accountId, { page, perPage })` | `GET /accounting/account/{id}/users/clients` | +| `getClient(accountId, clientId)` | `GET .../users/clients/{clientId}` | +| `findClientsByEmail(accountId, email)` | `GET .../users/clients?search[email]=` | +| `createClient(accountId, client)` | `POST .../users/clients` | +| `updateClient(accountId, clientId, client)` | `PUT .../users/clients/{clientId}` | +| `getInvoice(accountId, invoiceId)` | `GET /accounting/account/{id}/invoices/invoices/{invoiceId}` | +| `listClientInvoices(accountId, clientId, perPage)` | `GET .../invoices/invoices?search[customerid]=` | +| `getPayment(accountId, paymentId)` | `GET /accounting/account/{id}/payments/payments/{paymentId}` | +| `listClientPayments(accountId, clientId, perPage)` | `GET .../payments/payments?search[clientid]=` | +| `listCallbacks(accountId, { page, perPage })` | `GET /events/account/{id}/events/callbacks` | +| `getCallback(accountId, callbackId)` | `GET .../events/callbacks/{callbackId}` | +| `createCallback(accountId, { event, uri })` | `POST .../events/callbacks` | +| `verifyCallback(accountId, callbackId, verifier)` | `PUT .../events/callbacks/{callbackId}` | +| `resendCallbackVerification(accountId, callbackId)` | `PUT .../events/callbacks/{callbackId}` | +| `deleteCallback(accountId, callbackId)` | `DELETE .../events/callbacks/{callbackId}` | + +Helpers: `webUrls.client(id)`, `webUrls.invoice(accountId, invoiceId)` and `webUrls.invoices(search)` build FreshBooks web app links; `invoiceStatusName(status)` maps a numeric invoice status to its name. + +## Tests + +```bash +npm test +``` + +HTTP is mocked with `nock`; no FreshBooks credentials are needed. diff --git a/packages/v1-ready/freshbooks/api.js b/packages/v1-ready/freshbooks/api.js new file mode 100644 index 00000000..926c8120 --- /dev/null +++ b/packages/v1-ready/freshbooks/api.js @@ -0,0 +1,285 @@ +const { AsyncLocalStorage } = require('node:async_hooks'); +const { OAuth2Requester } = require('@friggframework/core'); + +const tokenRequestContext = new AsyncLocalStorage(); + +const API_VERSION = 'alpha'; +const WEB_APP = 'https://my.freshbooks.com/#'; +const INVOICE_STATUSES = { + 0: 'Disputed', + 1: 'Draft', + 2: 'Sent', + 3: 'Viewed', + 4: 'Paid', + 5: 'Auto Paid', + 6: 'Retry', + 7: 'Failed', + 8: 'Partial', +}; + +const jsonHeaders = () => ({ + 'Content-Type': 'application/json', + 'Api-Version': API_VERSION, +}); + +class Api extends OAuth2Requester { + constructor(params) { + super(params); + this.baseUrl = 'https://api.freshbooks.com'; + this.authorizationUri = 'https://auth.freshbooks.com/oauth/authorize/'; + this.tokenUri = `${this.baseUrl}/auth/oauth/token`; + this.freshbooksUserId = params?.freshbooksUserId ?? null; + this.URLs = { + userDetails: '/auth/api/v1/users/me', + clients: (accountId) => + `/accounting/account/${accountId}/users/clients`, + client: (accountId, clientId) => + `/accounting/account/${accountId}/users/clients/${clientId}`, + invoices: (accountId) => + `/accounting/account/${accountId}/invoices/invoices`, + invoice: (accountId, invoiceId) => + `/accounting/account/${accountId}/invoices/invoices/${invoiceId}`, + payments: (accountId) => + `/accounting/account/${accountId}/payments/payments`, + payment: (accountId, paymentId) => + `/accounting/account/${accountId}/payments/payments/${paymentId}`, + callbacks: (accountId) => + `/events/account/${accountId}/events/callbacks`, + callback: (accountId, callbackId) => + `/events/account/${accountId}/events/callbacks/${callbackId}`, + }; + this.webUrls = { + client: (clientId) => `${WEB_APP}/client/${clientId}`, + invoice: (accountId, invoiceId) => + `${WEB_APP}/invoice/${accountId}-${invoiceId}`, + invoices: (search) => + search + ? `${WEB_APP}/invoices?q=${encodeURIComponent(search)}` + : `${WEB_APP}/invoices`, + }; + } + + invoiceStatusName(status) { + return INVOICE_STATUSES[status] ?? ''; + } + + getAuthorizationUri() { + const query = new URLSearchParams({ + client_id: this.client_id, + response_type: 'code', + redirect_uri: this.redirect_uri, + }); + if (this.state) { + query.set('state', this.state); + } + return `${this.authorizationUri}?${query}`; + } + + async addAuthHeaders(headers) { + const authHeaders = await super.addAuthHeaders(headers); + authHeaders['Api-Version'] = API_VERSION; + return authHeaders; + } + + // FreshBooks refresh tokens and codes are single-use: a retry after a + // lost 2xx re-sends a grant FreshBooks already consumed. Starting past + // the last backoff slot sends the token request once. + async _rawRequest(url, options, attempt = 0) { + const tokenRequestAttempt = + url === this.tokenUri ? this.backOff.length : attempt; + return super._rawRequest(url, options, tokenRequestAttempt); + } + + // A 401 on the token request itself must not start a refresh: that + // would post a second single-use grant. + _refreshAuthOnce() { + if (tokenRequestContext.getStore() === this) { + return Promise.resolve(false); + } + return super._refreshAuthOnce(); + } + + async getTokenFromCode(code) { + return this._requestToken({ grant_type: 'authorization_code', code }); + } + + async refreshAccessToken({ refresh_token }) { + this.access_token = undefined; + return this._requestToken({ + grant_type: 'refresh_token', + refresh_token, + }); + } + + async _requestToken(grantFields) { + const response = await tokenRequestContext.run(this, () => + this._post({ + url: this.tokenUri, + headers: jsonHeaders(), + body: { + client_id: this.client_id, + client_secret: this.client_secret, + redirect_uri: this.redirect_uri, + ...grantFields, + }, + }) + ); + await this.setTokens(response); + return response; + } + + async getUserDetails() { + const { response } = await this._get({ + url: `${this.baseUrl}${this.URLs.userDetails}`, + }); + if (response?.id == null) { + throw new Error( + 'FreshBooks users/me response is missing the user id' + ); + } + this.freshbooksUserId = String(response.id); + return response; + } + + async listClients(accountId, { page = 1, perPage = 100 } = {}) { + const { response } = await this._get({ + url: `${this.baseUrl}${this.URLs.clients(accountId)}`, + query: { page, per_page: perPage, sort: 'updated_desc' }, + }); + const { clients, pages } = response.result; + return { clients, pages }; + } + + async getClient(accountId, clientId) { + const { response } = await this._get({ + url: `${this.baseUrl}${this.URLs.client(accountId, clientId)}`, + }); + return response.result.client; + } + + async findClientsByEmail(accountId, email) { + if (typeof email !== 'string' || !email.trim()) { + throw new Error('findClientsByEmail requires a non-empty email'); + } + const { response } = await this._get({ + url: `${this.baseUrl}${this.URLs.clients(accountId)}`, + query: { 'search[email]': email.trim(), sort: 'updated_desc' }, + }); + return response.result.clients; + } + + async createClient(accountId, client) { + const { response } = await this._post({ + url: `${this.baseUrl}${this.URLs.clients(accountId)}`, + headers: jsonHeaders(), + body: { client }, + }); + return response.result.client; + } + + async updateClient(accountId, clientId, client) { + const { response } = await this._put({ + url: `${this.baseUrl}${this.URLs.client(accountId, clientId)}`, + headers: jsonHeaders(), + body: { client }, + }); + return response.result.client; + } + + async getInvoice(accountId, invoiceId) { + const { response } = await this._get({ + url: `${this.baseUrl}${this.URLs.invoice(accountId, invoiceId)}`, + }); + return response.result.invoice; + } + + async getPayment(accountId, paymentId) { + const { response } = await this._get({ + url: `${this.baseUrl}${this.URLs.payment(accountId, paymentId)}`, + query: { 'include[]': 'invoice' }, + }); + return response.result.payment; + } + + async listClientInvoices(accountId, clientId, perPage) { + const { response } = await this._get({ + url: `${this.baseUrl}${this.URLs.invoices(accountId)}`, + query: { + 'search[customerid]': clientId, + sort: 'invoice_date_desc', + per_page: perPage, + }, + }); + return { + records: response.result.invoices, + total: response.result.total, + }; + } + + async listClientPayments(accountId, clientId, perPage) { + const { response } = await this._get({ + url: `${this.baseUrl}${this.URLs.payments(accountId)}`, + query: { + 'search[clientid]': clientId, + 'include[]': 'invoice', + sort: 'updated_desc', + per_page: perPage, + }, + }); + return { + records: response.result.payments, + total: response.result.total, + }; + } + + async listCallbacks(accountId, { page = 1, perPage = 100 } = {}) { + const { response } = await this._get({ + url: `${this.baseUrl}${this.URLs.callbacks(accountId)}`, + query: { page, per_page: perPage }, + }); + const { callbacks, pages } = response.result; + return { callbacks, pages }; + } + + async getCallback(accountId, callbackId) { + const { response } = await this._get({ + url: `${this.baseUrl}${this.URLs.callback(accountId, callbackId)}`, + }); + return response.result.callback; + } + + async createCallback(accountId, { event, uri }) { + const { response } = await this._post({ + url: `${this.baseUrl}${this.URLs.callbacks(accountId)}`, + headers: jsonHeaders(), + body: { callback: { event, uri } }, + }); + return response.result.callback; + } + + async verifyCallback(accountId, callbackId, verifier) { + const { response } = await this._put({ + url: `${this.baseUrl}${this.URLs.callback(accountId, callbackId)}`, + headers: jsonHeaders(), + body: { callback: { callback_id: callbackId, verifier } }, + }); + return response.result.callback; + } + + async resendCallbackVerification(accountId, callbackId) { + const { response } = await this._put({ + url: `${this.baseUrl}${this.URLs.callback(accountId, callbackId)}`, + headers: jsonHeaders(), + body: { callback: { resend: true } }, + }); + return response.result.callback; + } + + async deleteCallback(accountId, callbackId) { + await this._delete({ + url: `${this.baseUrl}${this.URLs.callback(accountId, callbackId)}`, + }); + } +} + +module.exports = { Api }; diff --git a/packages/v1-ready/freshbooks/defaultConfig.json b/packages/v1-ready/freshbooks/defaultConfig.json new file mode 100644 index 00000000..9a6634a6 --- /dev/null +++ b/packages/v1-ready/freshbooks/defaultConfig.json @@ -0,0 +1,9 @@ +{ + "name": "freshbooks", + "label": "FreshBooks", + "productUrl": "https://www.freshbooks.com", + "apiDocs": "https://www.freshbooks.com/api/start", + "logoUrl": "https://friggframework.org/assets/img/freshbooks-icon.png", + "categories": ["Accounting"], + "description": "FreshBooks cloud accounting" +} diff --git a/packages/v1-ready/freshbooks/definition.js b/packages/v1-ready/freshbooks/definition.js new file mode 100644 index 00000000..2ba22caa --- /dev/null +++ b/packages/v1-ready/freshbooks/definition.js @@ -0,0 +1,81 @@ +const { Api } = require('./api'); +const config = require('./defaultConfig.json'); + +const chosenBusiness = (user, accountId) => { + const businesses = (user.business_memberships ?? []) + .map((membership) => membership?.business) + .filter((business) => business?.account_id); + if (accountId) { + const business = businesses.find( + (candidate) => String(candidate.account_id) === String(accountId) + ); + if (!business) { + throw new Error( + `FreshBooks business not available to this user: ${accountId}` + ); + } + return business; + } + if (businesses.length === 0) { + throw new Error( + `FreshBooks user ${user.id} has no FreshBooks business to connect` + ); + } + if (businesses.length > 1) { + const choices = businesses + .map((business) => `${business.name} (${business.account_id})`) + .join(', '); + throw new Error( + `FreshBooks user ${user.id} belongs to multiple FreshBooks businesses; retry with account_id set to one of: ${choices}` + ); + } + return businesses[0]; +}; + +const Definition = { + API: Api, + getName: () => config.name, + moduleName: config.name, + modelName: 'FreshBooks', + requiredAuthMethods: { + getToken: async (api, params) => api.getTokenFromCode(params.code), + getEntityDetails: async ( + api, + callbackParams, + tokenResponse, + userId + ) => { + const business = chosenBusiness( + await api.getUserDetails(), + callbackParams?.account_id + ); + return { + identifiers: { + externalId: String(business.account_id), + user: userId, + }, + details: { name: business.name }, + }; + }, + getCredentialDetails: async (api, userId) => { + const externalId = + api.freshbooksUserId ?? (await api.getUserDetails()).id; + return { + identifiers: { externalId: String(externalId), userId }, + details: {}, + }; + }, + apiPropertiesToPersist: { + credential: ['access_token', 'refresh_token', 'freshbooksUserId'], + entity: [], + }, + testAuthRequest: async (api) => api.getUserDetails(), + }, + env: { + client_id: process.env.FRESHBOOKS_CLIENT_ID, + client_secret: process.env.FRESHBOOKS_CLIENT_SECRET, + redirect_uri: `${process.env.REDIRECT_URI}/${config.name}`, + }, +}; + +module.exports = { Definition }; diff --git a/packages/v1-ready/freshbooks/index.js b/packages/v1-ready/freshbooks/index.js new file mode 100644 index 00000000..232621cc --- /dev/null +++ b/packages/v1-ready/freshbooks/index.js @@ -0,0 +1,5 @@ +const { Api } = require('./api'); +const { Definition } = require('./definition'); +const Config = require('./defaultConfig.json'); + +module.exports = { Api, Definition, Config }; diff --git a/packages/v1-ready/freshbooks/package.json b/packages/v1-ready/freshbooks/package.json new file mode 100644 index 00000000..9adfd480 --- /dev/null +++ b/packages/v1-ready/freshbooks/package.json @@ -0,0 +1,32 @@ +{ + "name": "@friggframework/api-module-freshbooks", + "version": "1.0.0", + "prettier": "@friggframework/prettier-config", + "description": "FreshBooks API module that lets the Frigg Framework interact with FreshBooks identity, clients, invoices, payments, and webhook callbacks", + "main": "index.js", + "scripts": { + "lint:fix": "prettier --write --loglevel error .", + "test": "jest" + }, + "keywords": [ + "frigg", + "freshbooks", + "api-module", + "accounting", + "oauth2" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@friggframework/core": "^2.0.0-next.126" + }, + "devDependencies": { + "@friggframework/prettier-config": "^1.2.2", + "jest": "^28.1.3", + "nock": "^13.5.4", + "prettier": "^2.7.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/v1-ready/freshbooks/tests/api.test.js b/packages/v1-ready/freshbooks/tests/api.test.js new file mode 100644 index 00000000..d55f3944 --- /dev/null +++ b/packages/v1-ready/freshbooks/tests/api.test.js @@ -0,0 +1,814 @@ +const nock = require('nock'); +const { Api } = require('../api'); +const { TOKEN_RESPONSE, userWith, useMockedHttp } = require('./fixtures'); + +const createApi = (params = {}) => + new Api({ + client_id: 'client-id', + client_secret: 'client-secret', + redirect_uri: 'https://app.example.com/redirect/freshbooks', + ...params, + }); + +describe('FreshBooks Api', () => { + useMockedHttp(); + + describe('getAuthorizationUri', () => { + it('points at the FreshBooks authorize endpoint with the client, redirect and state', () => { + const api = createApi({ state: 'abc123' }); + + const url = new URL(api.getAuthorizationUri()); + + expect(`${url.origin}${url.pathname}`).toBe( + 'https://auth.freshbooks.com/oauth/authorize/' + ); + expect(Object.fromEntries(url.searchParams)).toEqual({ + client_id: 'client-id', + response_type: 'code', + redirect_uri: 'https://app.example.com/redirect/freshbooks', + state: 'abc123', + }); + }); + }); + + describe('webUrls', () => { + it('links to clients, invoices and an invoice search in the FreshBooks web app', () => { + const { webUrls } = createApi(); + + expect(webUrls.client(7)).toBe( + 'https://my.freshbooks.com/#/client/7' + ); + expect(webUrls.invoice('ej6eR', 11)).toBe( + 'https://my.freshbooks.com/#/invoice/ej6eR-11' + ); + expect(webUrls.invoices()).toBe( + 'https://my.freshbooks.com/#/invoices' + ); + expect(webUrls.invoices('Left Hook')).toBe( + 'https://my.freshbooks.com/#/invoices?q=Left%20Hook' + ); + }); + }); + + describe('invoiceStatusName', () => { + it.each([ + [2, 'Sent'], + [4, 'Paid'], + [8, 'Partial'], + [42, ''], + ])('names invoice status %p as %p', (status, name) => { + expect(createApi().invoiceStatusName(status)).toBe(name); + }); + }); + + describe('getTokenFromCode', () => { + it('exchanges the code at the FreshBooks token endpoint with a JSON body', async () => { + const scope = nock('https://api.freshbooks.com') + .post('/auth/oauth/token', { + grant_type: 'authorization_code', + client_id: 'client-id', + client_secret: 'client-secret', + redirect_uri: 'https://app.example.com/redirect/freshbooks', + code: 'auth-code', + }) + .matchHeader('content-type', 'application/json') + .reply(200, TOKEN_RESPONSE); + const api = createApi(); + + const response = await api.getTokenFromCode('auth-code'); + + expect(scope.isDone()).toBe(true); + expect(response).toEqual(TOKEN_RESPONSE); + expect(api.access_token).toBe('new-access'); + expect(api.refresh_token).toBe('new-refresh'); + }); + + it('sends the single-use code once when FreshBooks answers 502', async () => { + const scope = nock('https://api.freshbooks.com') + .post('/auth/oauth/token') + .times(4) + .reply(502, {}); + let tokenRequests = 0; + scope.on('request', () => tokenRequests++); + + await expect( + createApi({ backOff: [0, 0, 0] }).getTokenFromCode('auth-code') + ).rejects.toMatchObject({ + statusCode: 502, + }); + + expect(tokenRequests).toBe(1); + }); + + it('sends the single-use code once when FreshBooks answers 401', async () => { + const scope = nock('https://api.freshbooks.com') + .post('/auth/oauth/token') + .times(4) + .reply(401, {}); + let tokenRequests = 0; + scope.on('request', () => tokenRequests++); + + await expect( + createApi({ backOff: [0, 0, 0] }).getTokenFromCode('auth-code') + ).rejects.toMatchObject({ statusCode: 401 }); + + expect(tokenRequests).toBe(1); + }); + }); + + describe('refreshAccessToken', () => { + it('replaces the single-use refresh token and notifies the delegate so it is persisted', async () => { + const scope = nock('https://api.freshbooks.com') + .post('/auth/oauth/token', { + grant_type: 'refresh_token', + client_id: 'client-id', + client_secret: 'client-secret', + redirect_uri: 'https://app.example.com/redirect/freshbooks', + refresh_token: 'old-refresh', + }) + .reply(200, TOKEN_RESPONSE); + const delegate = { receiveNotification: jest.fn() }; + const api = createApi({ + delegate, + access_token: 'old-access', + refresh_token: 'old-refresh', + }); + + await api.refreshAccessToken({ refresh_token: 'old-refresh' }); + + expect(scope.isDone()).toBe(true); + expect(api.refresh_token).toBe('new-refresh'); + expect(api.access_token).toBe('new-access'); + expect(delegate.receiveNotification).toHaveBeenCalledWith( + api, + api.DLGT_TOKEN_UPDATE, + null + ); + }); + + it('sends the single-use refresh token once when FreshBooks answers 502', async () => { + const scope = nock('https://api.freshbooks.com') + .post('/auth/oauth/token') + .times(4) + .reply(502, {}); + let tokenRequests = 0; + scope.on('request', () => tokenRequests++); + const api = createApi({ + backOff: [0, 0, 0], + refresh_token: 'old-refresh', + }); + + await expect( + api.refreshAccessToken({ refresh_token: 'old-refresh' }) + ).rejects.toMatchObject({ + statusCode: 502, + }); + + expect(tokenRequests).toBe(1); + expect(api.refresh_token).toBe('old-refresh'); + }); + + it('sends the single-use refresh token once when FreshBooks answers 401', async () => { + const scope = nock('https://api.freshbooks.com') + .post('/auth/oauth/token') + .times(4) + .reply(401, {}); + let tokenRequests = 0; + scope.on('request', () => tokenRequests++); + const api = createApi({ + backOff: [0, 0, 0], + refresh_token: 'old-refresh', + }); + + await expect( + api.refreshAccessToken({ refresh_token: 'old-refresh' }) + ).rejects.toMatchObject({ statusCode: 401 }); + + expect(tokenRequests).toBe(1); + }); + + describe('sends the single-use refresh token exactly once', () => { + const tokenEndpoint = () => + nock('https://api.freshbooks.com').post('/auth/oauth/token'); + const notifications = (onReload = async () => null) => ({ + receiveNotification: jest.fn((_, type) => + type === 'CREDENTIAL_RELOAD' ? onReload() : undefined + ), + }); + const refreshingApi = (delegate = notifications()) => + createApi({ + delegate, + access_token: 'old-access', + refresh_token: 'old-refresh', + backOff: [0, 0, 0], + credentialReloadBackoffMs: [0], + }); + const countRequests = (scope) => { + let count = 0; + scope.on('request', () => count++); + return () => count; + }; + + it('a 502 on the refresh after a 401 is one token request and a transport failure', async () => { + nock('https://api.freshbooks.com') + .get('/auth/api/v1/users/me') + .reply(401, {}); + const tokenRequests = countRequests( + tokenEndpoint().times(4).reply(502, {}) + ); + const delegate = notifications(); + const api = refreshingApi(delegate); + + await expect(api.getUserDetails()).rejects.toMatchObject({ + isTokenRefreshTransportFailure: true, + statusCode: 502, + }); + + expect(tokenRequests()).toBe(1); + expect(api.refresh_token).toBe('old-refresh'); + expect(delegate.receiveNotification).not.toHaveBeenCalledWith( + api, + api.DLGT_INVALID_AUTH, + expect.anything() + ); + }); + + it.each([ + [ + 'a 429', + (interceptor) => interceptor.times(4).reply(429, {}), + 429, + ], + [ + 'a network error', + (interceptor) => + interceptor + .times(4) + .replyWithError({ code: 'ECONNRESET' }), + undefined, + ], + ])( + '%s is one token request and a transport failure', + async (_, reply, statusCode) => { + const tokenRequests = countRequests(reply(tokenEndpoint())); + const delegate = notifications(); + const api = refreshingApi(delegate); + + const error = await api.refreshAuth().catch((e) => e); + + expect(error.isTokenRefreshTransportFailure).toBe(true); + expect(error.statusCode).toBe(statusCode); + expect(tokenRequests()).toBe(1); + expect(api.refresh_token).toBe('old-refresh'); + expect( + delegate.receiveNotification + ).not.toHaveBeenCalledWith( + api, + api.DLGT_INVALID_AUTH, + expect.anything() + ); + } + ); + + it('a 400 invalid_grant is one token request and invalidates the credential', async () => { + const tokenRequests = countRequests( + tokenEndpoint() + .times(4) + .reply(400, { error: 'invalid_grant' }) + ); + const delegate = notifications(); + const api = refreshingApi(delegate); + + await expect(api.refreshAuth()).resolves.toBe(false); + + expect(tokenRequests()).toBe(1); + expect(delegate.receiveNotification).toHaveBeenCalledWith( + api, + api.DLGT_INVALID_AUTH, + { + statusCode: 400, + } + ); + }); + + it('a 400 invalid_grant still adopts the newer credential another invocation stored', async () => { + const tokenRequests = countRequests( + tokenEndpoint() + .times(4) + .reply(400, { error: 'invalid_grant' }) + ); + const reloads = [ + null, + { + access_token: 'winner-access', + refresh_token: 'winner-refresh', + }, + ]; + const api = refreshingApi( + notifications(async () => reloads.shift()) + ); + + await expect(api.refreshAuth()).resolves.toBe(true); + + expect(tokenRequests()).toBe(1); + expect(api.access_token).toBe('winner-access'); + expect(api.refresh_token).toBe('winner-refresh'); + }); + + it('a 200 sets the tokens from one token request', async () => { + const tokenRequests = countRequests( + tokenEndpoint().times(2).reply(200, TOKEN_RESPONSE) + ); + const delegate = notifications(); + const api = refreshingApi(delegate); + + await expect(api.refreshAuth()).resolves.toBe(true); + + expect(tokenRequests()).toBe(1); + expect(api.access_token).toBe('new-access'); + expect(api.refresh_token).toBe('new-refresh'); + expect(delegate.receiveNotification).toHaveBeenCalledWith( + api, + api.DLGT_TOKEN_UPDATE, + null + ); + }); + }); + }); + + describe('getUserDetails', () => { + it('returns the response of users/me with the bearer token', async () => { + const user = userWith(); + const scope = nock('https://api.freshbooks.com', { + reqheaders: { + authorization: 'Bearer live-access', + 'api-version': 'alpha', + }, + }) + .get('/auth/api/v1/users/me') + .reply(200, { response: user }); + const api = createApi({ + access_token: 'live-access', + refresh_token: 'live-refresh', + }); + + const details = await api.getUserDetails(); + + expect(scope.isDone()).toBe(true); + expect(details).toEqual(user); + }); + + it('remembers the FreshBooks user id for later persistence', async () => { + nock('https://api.freshbooks.com') + .get('/auth/api/v1/users/me') + .reply(200, { response: { id: 42 } }); + const api = createApi({ + access_token: 'live-access', + refresh_token: 'live-refresh', + }); + + await api.getUserDetails(); + + expect(api.freshbooksUserId).toBe('42'); + }); + + it.each([{}, { id: null }])( + 'throws a clear error when the response has no id (%p)', + async (body) => { + nock('https://api.freshbooks.com') + .get('/auth/api/v1/users/me') + .reply(200, { response: body }); + const api = createApi({ + access_token: 'live-access', + refresh_token: 'live-refresh', + }); + + await expect(api.getUserDetails()).rejects.toThrow(/user id/i); + expect(api.freshbooksUserId).toBeNull(); + } + ); + + it('restores the user id from persisted credential params', () => { + expect(createApi({ freshbooksUserId: '42' }).freshbooksUserId).toBe( + '42' + ); + }); + }); + + describe('accounting endpoints', () => { + const ACCOUNT = 'acct-1'; + const CLIENTS_PATH = `/accounting/account/${ACCOUNT}/users/clients`; + const CALLBACKS_PATH = `/events/account/${ACCOUNT}/events/callbacks`; + const authedApi = (params = {}) => + createApi({ + access_token: 'live-access', + refresh_token: 'live-refresh', + backOff: [0], + ...params, + }); + const freshbooks = () => + nock('https://api.freshbooks.com', { + reqheaders: { + authorization: 'Bearer live-access', + 'api-version': 'alpha', + }, + }); + const result = (payload) => ({ response: { result: payload } }); + + describe('listClients', () => { + it('requests the page newest-updated first and returns the clients with the page count', async () => { + const clients = [{ id: 1 }, { id: 2 }]; + const scope = freshbooks() + .get(CLIENTS_PATH) + .query({ page: '2', per_page: '50', sort: 'updated_desc' }) + .reply( + 200, + result({ + clients, + page: 2, + pages: 5, + per_page: 50, + total: 230, + }) + ); + + const page = await authedApi().listClients(ACCOUNT, { + page: 2, + perPage: 50, + }); + + expect(scope.isDone()).toBe(true); + expect(page).toEqual({ clients, pages: 5 }); + }); + + it('defaults to page 1 with 100 per page', async () => { + const scope = freshbooks() + .get(CLIENTS_PATH) + .query({ page: '1', per_page: '100', sort: 'updated_desc' }) + .reply(200, result({ clients: [], page: 1, pages: 0 })); + + const page = await authedApi().listClients(ACCOUNT); + + expect(scope.isDone()).toBe(true); + expect(page).toEqual({ clients: [], pages: 0 }); + }); + }); + + describe('getClient', () => { + it('returns the unwrapped client', async () => { + const scope = freshbooks() + .get(`${CLIENTS_PATH}/7`) + .reply(200, result({ client: { id: 7, email: 'a@b.co' } })); + + const client = await authedApi().getClient(ACCOUNT, 7); + + expect(scope.isDone()).toBe(true); + expect(client).toEqual({ id: 7, email: 'a@b.co' }); + }); + }); + + describe('findClientsByEmail', () => { + it('searches by email newest-updated first and returns the clients array', async () => { + const scope = freshbooks() + .get(CLIENTS_PATH) + .query({ + 'search[email]': 'a+b@example.com', + sort: 'updated_desc', + }) + .reply(200, result({ clients: [{ id: 3 }] })); + + const clients = await authedApi().findClientsByEmail( + ACCOUNT, + 'a+b@example.com' + ); + + expect(scope.isDone()).toBe(true); + expect(clients).toEqual([{ id: 3 }]); + }); + + it('sends the email trimmed', async () => { + const scope = freshbooks() + .get(CLIENTS_PATH) + .query({ 'search[email]': 'a@b.co', sort: 'updated_desc' }) + .reply(200, result({ clients: [] })); + + await authedApi().findClientsByEmail(ACCOUNT, ' a@b.co '); + + expect(scope.isDone()).toBe(true); + }); + + it.each(['', ' ', undefined, null])( + 'throws without calling FreshBooks when email is %p', + async (email) => { + const scope = freshbooks() + .get(CLIENTS_PATH) + .query(true) + .reply(200, {}); + + await expect( + authedApi().findClientsByEmail(ACCOUNT, email) + ).rejects.toThrow(/email/i); + expect(scope.isDone()).toBe(false); + } + ); + }); + + describe('createClient', () => { + it('posts the client wrapped in a client key and returns the created client', async () => { + const scope = freshbooks() + .post(CLIENTS_PATH, { + client: { email: 'a@b.co', fname: 'Ada' }, + }) + .reply(200, result({ client: { id: 9, email: 'a@b.co' } })); + + const client = await authedApi().createClient(ACCOUNT, { + email: 'a@b.co', + fname: 'Ada', + }); + + expect(scope.isDone()).toBe(true); + expect(client).toEqual({ id: 9, email: 'a@b.co' }); + }); + }); + + describe('updateClient', () => { + it('puts the client wrapped in a client key and returns the updated client', async () => { + const scope = freshbooks() + .put(`${CLIENTS_PATH}/9`, { client: { fname: 'Grace' } }) + .reply(200, result({ client: { id: 9, fname: 'Grace' } })); + + const client = await authedApi().updateClient(ACCOUNT, 9, { + fname: 'Grace', + }); + + expect(scope.isDone()).toBe(true); + expect(client).toEqual({ id: 9, fname: 'Grace' }); + }); + }); + + describe('getInvoice', () => { + it('returns the unwrapped invoice', async () => { + const scope = freshbooks() + .get(`/accounting/account/${ACCOUNT}/invoices/invoices/11`) + .reply(200, result({ invoice: { id: 11, userid: 7 } })); + + const invoice = await authedApi().getInvoice(ACCOUNT, 11); + + expect(scope.isDone()).toBe(true); + expect(invoice).toEqual({ id: 11, userid: 7 }); + }); + }); + + describe('getPayment', () => { + it('includes the invoice as a query string, not in the path', async () => { + let requestedPath; + const scope = freshbooks() + .get(/\/payments\/payments\/21/) + .reply(200, function () { + requestedPath = this.req.path; + return result({ payment: { id: 21, invoiceid: 11 } }); + }); + + const payment = await authedApi().getPayment(ACCOUNT, 21); + + expect(scope.isDone()).toBe(true); + expect(requestedPath).toBe( + `/accounting/account/${ACCOUNT}/payments/payments/21?include%5B%5D=invoice` + ); + expect(payment).toEqual({ id: 21, invoiceid: 11 }); + }); + }); + + describe('listClientInvoices', () => { + it("returns the client's newest invoices and how many it has", async () => { + let requestedPath; + const scope = freshbooks() + .get(/\/invoices\/invoices/) + .reply(200, function () { + requestedPath = this.req.path; + return result({ invoices: [{ id: 11 }], total: 240 }); + }); + + const page = await authedApi().listClientInvoices( + ACCOUNT, + 7, + 5 + ); + + expect(scope.isDone()).toBe(true); + expect(requestedPath).toBe( + `/accounting/account/${ACCOUNT}/invoices/invoices?search%5Bcustomerid%5D=7&sort=invoice_date_desc&per_page=5` + ); + expect(page).toEqual({ records: [{ id: 11 }], total: 240 }); + }); + }); + + describe('listClientPayments', () => { + it("returns the client's newest payments with their invoice and how many it has", async () => { + let requestedPath; + const scope = freshbooks() + .get(/\/payments\/payments/) + .reply(200, function () { + requestedPath = this.req.path; + return result({ payments: [{ id: 21 }], total: 130 }); + }); + + const page = await authedApi().listClientPayments( + ACCOUNT, + 7, + 5 + ); + + expect(scope.isDone()).toBe(true); + expect(requestedPath).toBe( + `/accounting/account/${ACCOUNT}/payments/payments?search%5Bclientid%5D=7&include%5B%5D=invoice&sort=updated_desc&per_page=5` + ); + expect(page).toEqual({ records: [{ id: 21 }], total: 130 }); + }); + }); + + describe('callbacks', () => { + it('createCallback posts event and uri wrapped in a callback key', async () => { + const scope = freshbooks() + .post(CALLBACKS_PATH, { + callback: { + event: 'invoice.create', + uri: 'https://hook.example.com/x', + }, + }) + .reply( + 200, + result({ callback: { callbackid: 5, verified: false } }) + ); + + const callback = await authedApi().createCallback(ACCOUNT, { + event: 'invoice.create', + uri: 'https://hook.example.com/x', + }); + + expect(scope.isDone()).toBe(true); + expect(callback).toEqual({ callbackid: 5, verified: false }); + }); + + it('verifyCallback puts the callback id and verifier', async () => { + const scope = freshbooks() + .put(`${CALLBACKS_PATH}/5`, { + callback: { callback_id: 5, verifier: 'abc' }, + }) + .reply( + 200, + result({ callback: { callbackid: 5, verified: true } }) + ); + + const callback = await authedApi().verifyCallback( + ACCOUNT, + 5, + 'abc' + ); + + expect(scope.isDone()).toBe(true); + expect(callback).toEqual({ callbackid: 5, verified: true }); + }); + + it('resendCallbackVerification asks FreshBooks to resend the verifier', async () => { + const scope = freshbooks() + .put(`${CALLBACKS_PATH}/5`, { callback: { resend: true } }) + .reply( + 200, + result({ callback: { callbackid: 5, verified: false } }) + ); + + const callback = await authedApi().resendCallbackVerification( + ACCOUNT, + 5 + ); + + expect(scope.isDone()).toBe(true); + expect(callback).toEqual({ callbackid: 5, verified: false }); + }); + + it('listCallbacks returns one page of callbacks with the page count', async () => { + const callbacks = [ + { + callbackid: 5, + event: 'invoice', + uri: 'https://x', + verified: true, + }, + ]; + const scope = freshbooks() + .get(CALLBACKS_PATH) + .query({ page: '2', per_page: '100' }) + .reply(200, result({ callbacks, page: 2, pages: 3 })); + + const page = await authedApi().listCallbacks(ACCOUNT, { + page: 2, + }); + + expect(scope.isDone()).toBe(true); + expect(page).toEqual({ callbacks, pages: 3 }); + }); + + it('getCallback returns the unwrapped callback', async () => { + const scope = freshbooks() + .get(`${CALLBACKS_PATH}/5`) + .reply( + 200, + result({ callback: { callbackid: 5, verified: true } }) + ); + + const callback = await authedApi().getCallback(ACCOUNT, 5); + + expect(scope.isDone()).toBe(true); + expect(callback).toEqual({ callbackid: 5, verified: true }); + }); + + it('deleteCallback deletes the callback', async () => { + const scope = freshbooks() + .delete(`${CALLBACKS_PATH}/5`) + .reply(200, {}); + + await authedApi().deleteCallback(ACCOUNT, 5); + + expect(scope.isDone()).toBe(true); + }); + }); + + describe('concurrent requests from different tenants', () => { + it('sends each request with its own bearer token', async () => { + const tenant = (token, email) => + nock('https://api.freshbooks.com', { + reqheaders: { authorization: `Bearer ${token}` }, + }) + .post(CLIENTS_PATH, { client: { email } }) + .reply(200, result({ client: { id: token } })); + const scopeA = tenant('token-a', 'a@x.co'); + const scopeB = tenant('token-b', 'b@x.co'); + + const [clientA, clientB] = await Promise.all([ + authedApi({ access_token: 'token-a' }).createClient( + ACCOUNT, + { + email: 'a@x.co', + } + ), + authedApi({ access_token: 'token-b' }).createClient( + ACCOUNT, + { + email: 'b@x.co', + } + ), + ]); + + expect(scopeA.isDone()).toBe(true); + expect(scopeB.isDone()).toBe(true); + expect(clientA).toEqual({ id: 'token-a' }); + expect(clientB).toEqual({ id: 'token-b' }); + }); + }); + + describe('error responses', () => { + it('propagates a 4xx with its status code', async () => { + freshbooks() + .get(`${CLIENTS_PATH}/7`) + .reply(404, { message: 'not found' }); + + await expect( + authedApi().getClient(ACCOUNT, 7) + ).rejects.toMatchObject({ + statusCode: 404, + }); + }); + }); + + describe('server errors on non-token requests', () => { + it('retries a 502 with backoff and returns the eventual 200', async () => { + const scope = freshbooks() + .get(`${CLIENTS_PATH}/7`) + .reply(502, {}) + .get(`${CLIENTS_PATH}/7`) + .reply(200, result({ client: { id: 7 } })); + + const client = await authedApi().getClient(ACCOUNT, 7); + + expect(scope.isDone()).toBe(true); + expect(client).toEqual({ id: 7 }); + }); + }); + + describe('rate limiting', () => { + it('retries a 429 and returns the eventual 200', async () => { + const scope = freshbooks() + .get(`${CLIENTS_PATH}/7`) + .reply(429, {}) + .get(`${CLIENTS_PATH}/7`) + .reply(200, result({ client: { id: 7 } })); + + const client = await authedApi().getClient(ACCOUNT, 7); + + expect(scope.isDone()).toBe(true); + expect(client).toEqual({ id: 7 }); + }); + }); + }); +}); diff --git a/packages/v1-ready/freshbooks/tests/definition.test.js b/packages/v1-ready/freshbooks/tests/definition.test.js new file mode 100644 index 00000000..053589b0 --- /dev/null +++ b/packages/v1-ready/freshbooks/tests/definition.test.js @@ -0,0 +1,248 @@ +const { USER, business, userWith, setFreshbooksEnv } = require('./fixtures'); + +describe('FreshBooks Definition', () => { + let Definition; + + beforeAll(() => { + setFreshbooksEnv(); + ({ Definition } = require('../definition')); + }); + + const apiReturning = (user) => ({ + getUserDetails: jest.fn().mockResolvedValue(user), + }); + + it('is named freshbooks and uses the Api class', () => { + const { Api } = require('../api'); + expect(Definition.moduleName).toBe('freshbooks'); + expect(Definition.getName()).toBe('freshbooks'); + expect(Definition.API).toBe(Api); + }); + + it('reads client credentials and redirect uri from the environment', () => { + expect(Definition.env).toEqual({ + client_id: 'client-id', + client_secret: 'client-secret', + redirect_uri: 'https://app.example.com/redirect/freshbooks', + }); + }); + + it('persists both tokens and the user id on the credential and nothing on the entity', () => { + expect(Definition.requiredAuthMethods.apiPropertiesToPersist).toEqual({ + credential: ['access_token', 'refresh_token', 'freshbooksUserId'], + entity: [], + }); + }); + + it('exchanges the callback code for tokens', async () => { + const api = { + getTokenFromCode: jest + .fn() + .mockResolvedValue({ access_token: 'a' }), + }; + + const result = await Definition.requiredAuthMethods.getToken(api, { + code: 'auth-code', + }); + + expect(api.getTokenFromCode).toHaveBeenCalledWith('auth-code'); + expect(result).toEqual({ access_token: 'a' }); + }); + + describe('getEntityDetails', () => { + const entityDetails = (user) => + Definition.requiredAuthMethods.getEntityDetails( + apiReturning(user), + {}, + {}, + 'user-1' + ); + + it('keys the entity by the business account_id', async () => { + await expect(entityDetails(USER)).resolves.toEqual({ + identifiers: { externalId: 'acct-1', user: 'user-1' }, + details: { name: 'Analytical Engines Ltd' }, + }); + }); + + it('throws a clear error when the user belongs to several businesses', async () => { + const user = userWith( + business('acct-1', 'One'), + business('acct-2', 'Two') + ); + + await expect(entityDetails(user)).rejects.toThrow( + /multiple FreshBooks businesses/i + ); + }); + + it('lists the choices as name (account_id) when several businesses need a choice', async () => { + const user = userWith( + business('acct-1', 'One'), + business('acct-2', 'Two') + ); + + await expect(entityDetails(user)).rejects.toThrow( + /One \(acct-1\), Two \(acct-2\)/ + ); + }); + + describe('with a chosen account_id', () => { + const user = userWith( + business('acct-1', 'One'), + business('acct-2', 'Two') + ); + const chosen = (accountId, target = user) => + Definition.requiredAuthMethods.getEntityDetails( + apiReturning(target), + { code: 'abc', account_id: accountId }, + {}, + 'user-1' + ); + + it('keys the entity by the chosen business among several', async () => { + await expect(chosen('acct-2')).resolves.toEqual({ + identifiers: { externalId: 'acct-2', user: 'user-1' }, + details: { name: 'Two' }, + }); + }); + + it('throws when the chosen business is not one of the user memberships', async () => { + await expect(chosen('acct-9')).rejects.toThrow( + /business not available to this user/i + ); + }); + + it('throws when the chosen business matches only a membership without an account_id', async () => { + await expect( + chosen( + 'acct-9', + userWith( + { business: { name: 'No id' } }, + business('acct-1', 'One') + ) + ) + ).rejects.toThrow(/business not available to this user/i); + }); + + it('uses the chosen business when the user has just one', async () => { + await expect(chosen('acct-1', USER)).resolves.toMatchObject({ + identifiers: { externalId: 'acct-1' }, + }); + }); + + it.each([' acct-2 ', 'ACCT-2', 'Acct-2'])( + 'rejects %j without trimming or case folding', + async (accountId) => { + await expect(chosen(accountId)).rejects.toThrow( + /business not available to this user/i + ); + } + ); + + it('connects a business_partner membership', async () => { + const partner = userWith( + { + role: 'owner', + business: { account_id: 'acct-1', name: 'One' }, + }, + { + role: 'business_partner', + business: { account_id: 'acct-2', name: 'Two' }, + } + ); + + await expect(chosen('acct-2', partner)).resolves.toEqual({ + identifiers: { externalId: 'acct-2', user: 'user-1' }, + details: { name: 'Two' }, + }); + }); + + it('ignores an empty account_id and falls back to the single business', async () => { + await expect(chosen('', USER)).resolves.toMatchObject({ + identifiers: { externalId: 'acct-1' }, + }); + }); + }); + + it('throws a clear error when the user belongs to no business', async () => { + await expect(entityDetails(userWith())).rejects.toThrow( + /no FreshBooks business/i + ); + }); + + it('throws a clear error when the only membership lacks an account_id', async () => { + await expect( + entityDetails(userWith({ business: { name: 'No id' } })) + ).rejects.toThrow(/no FreshBooks business/i); + }); + + it('throws a clear error when the only membership has a null business', async () => { + await expect( + entityDetails(userWith({ business: null })) + ).rejects.toThrow(/no FreshBooks business/i); + }); + + it('ignores memberships without an account_id next to a valid business', async () => { + const user = userWith( + { business: { name: 'No id' } }, + { business: null }, + business('acct-1', 'Analytical Engines Ltd') + ); + + await expect(entityDetails(user)).resolves.toEqual({ + identifiers: { externalId: 'acct-1', user: 'user-1' }, + details: { name: 'Analytical Engines Ltd' }, + }); + }); + + it('throws a clear error when memberships are missing from the response', async () => { + await expect(entityDetails({ id: 42 })).rejects.toThrow( + /no FreshBooks business/i + ); + }); + }); + + describe('getCredentialDetails', () => { + it('keys the credential by the persisted user id without a network call', async () => { + const api = { freshbooksUserId: '42', getUserDetails: jest.fn() }; + + const details = + await Definition.requiredAuthMethods.getCredentialDetails( + api, + 'user-1' + ); + + expect(api.getUserDetails).not.toHaveBeenCalled(); + expect(details).toEqual({ + identifiers: { externalId: '42', userId: 'user-1' }, + details: {}, + }); + }); + + it('falls back to users/me on first auth when no user id is known yet', async () => { + const api = apiReturning(USER); + + const details = + await Definition.requiredAuthMethods.getCredentialDetails( + api, + 'user-1' + ); + + expect(api.getUserDetails).toHaveBeenCalledTimes(1); + expect(details).toEqual({ + identifiers: { externalId: '42', userId: 'user-1' }, + details: {}, + }); + }); + }); + + it('tests auth by fetching users/me', async () => { + const api = apiReturning(USER); + + await expect( + Definition.requiredAuthMethods.testAuthRequest(api) + ).resolves.toEqual(USER); + expect(api.getUserDetails).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/v1-ready/freshbooks/tests/fixtures.js b/packages/v1-ready/freshbooks/tests/fixtures.js new file mode 100644 index 00000000..50d49882 --- /dev/null +++ b/packages/v1-ready/freshbooks/tests/fixtures.js @@ -0,0 +1,46 @@ +const nock = require('nock'); + +const REDIRECT_URI = 'https://app.example.com/redirect'; + +const TOKEN_RESPONSE = { + access_token: 'new-access', + refresh_token: 'new-refresh', + expires_in: 43200, + token_type: 'Bearer', +}; + +const business = (accountId, name) => ({ + business: { account_id: accountId, name }, +}); + +const userWith = (...memberships) => ({ + id: 42, + first_name: 'Ada', + last_name: 'Lovelace', + email: 'ada@example.com', + business_memberships: memberships, +}); + +const USER = userWith(business('acct-1', 'Analytical Engines Ltd')); + +const setFreshbooksEnv = () => { + process.env.FRESHBOOKS_CLIENT_ID = 'client-id'; + process.env.FRESHBOOKS_CLIENT_SECRET = 'client-secret'; + process.env.REDIRECT_URI = REDIRECT_URI; +}; + +const useMockedHttp = () => { + beforeAll(() => nock.disableNetConnect()); + afterEach(() => nock.cleanAll()); + afterAll(() => nock.enableNetConnect()); +}; + +module.exports = { + REDIRECT_URI, + TOKEN_RESPONSE, + USER, + business, + userWith, + setFreshbooksEnv, + useMockedHttp, +}; diff --git a/packages/v1-ready/freshbooks/tests/index.test.js b/packages/v1-ready/freshbooks/tests/index.test.js new file mode 100644 index 00000000..4ab52033 --- /dev/null +++ b/packages/v1-ready/freshbooks/tests/index.test.js @@ -0,0 +1,9 @@ +const { Api, Definition, Config } = require('..'); + +describe('package entry point', () => { + it('exports the Api, Definition and Config', () => { + expect(Definition.API).toBe(Api); + expect(Config.name).toBe('freshbooks'); + expect(Definition.getName()).toBe(Config.name); + }); +}); diff --git a/packages/v1-ready/freshbooks/tests/module.test.js b/packages/v1-ready/freshbooks/tests/module.test.js new file mode 100644 index 00000000..9dd349aa --- /dev/null +++ b/packages/v1-ready/freshbooks/tests/module.test.js @@ -0,0 +1,252 @@ +const nock = require('nock'); +const { + TOKEN_RESPONSE, + USER, + setFreshbooksEnv, + useMockedHttp, +} = require('./fixtures'); + +describe('FreshBooks module under core Module', () => { + let Module; + let Definition; + + beforeAll(() => { + setFreshbooksEnv(); + process.env.DB_TYPE = 'postgresql'; + ({ Module } = require('@friggframework/core/modules/module')); + ({ Definition } = require('../definition')); + }); + afterAll(() => { + delete process.env.DB_TYPE; + }); + useMockedHttp(); + + const STORED_CREDENTIAL = { + access_token: 'old-access', + refresh_token: 'old-refresh', + freshbooksUserId: '42', + }; + + const createCredentialStore = () => { + let stored = { id: 'credential-1', ...STORED_CREDENTIAL }; + return { + upsertCredential: jest.fn(async ({ details }) => { + stored = { id: 'credential-1', ...details }; + return stored; + }), + updateAuthenticationStatus: jest.fn(), + findCredentialById: jest.fn(async () => stored), + }; + }; + + const createModule = ( + credentialRepository = createCredentialStore(), + credentialReloadBackoffMs = [] + ) => { + const module = new Module({ + userId: 'user-1', + definition: Definition, + entity: { + id: 'entity-1', + credential: { + id: 'credential-1', + data: { ...STORED_CREDENTIAL }, + }, + }, + }); + module.credentialRepository = credentialRepository; + module.api.credentialReloadBackoffMs = credentialReloadBackoffMs; + return module; + }; + + it('persists the rotated refresh token even when users/me is failing', async () => { + const usersMe = nock('https://api.freshbooks.com') + .get('/auth/api/v1/users/me') + .reply(500) + .persist(); + nock('https://api.freshbooks.com') + .post('/auth/oauth/token') + .reply(200, TOKEN_RESPONSE); + const module = createModule(); + + await module.api.refreshAccessToken({ refresh_token: 'old-refresh' }); + + expect(usersMe.isDone()).toBe(false); + expect( + module.credentialRepository.upsertCredential + ).toHaveBeenCalledWith({ + identifiers: { externalId: '42', userId: 'user-1' }, + details: expect.objectContaining({ + access_token: 'new-access', + refresh_token: 'new-refresh', + freshbooksUserId: '42', + authIsValid: true, + }), + }); + }); + + it('refreshes after a 401 on a normal call and retries it with the new token', async () => { + const api = nock('https://api.freshbooks.com'); + api.get('/auth/api/v1/users/me') + .matchHeader('authorization', 'Bearer old-access') + .reply(401); + api.post( + '/auth/oauth/token', + (body) => body.refresh_token === 'old-refresh' + ).reply(200, TOKEN_RESPONSE); + api.get('/auth/api/v1/users/me') + .matchHeader('authorization', 'Bearer new-access') + .reply(200, { response: USER }); + const module = createModule(); + + const details = await module.api.getUserDetails(); + + expect(details).toEqual(USER); + expect(module.api.refresh_token).toBe('new-refresh'); + expect( + module.credentialRepository.upsertCredential + ).toHaveBeenCalledWith( + expect.objectContaining({ + details: expect.objectContaining({ + refresh_token: 'new-refresh', + }), + }) + ); + }); + + it('marks the credential invalid and rejects the call when the refresh is refused with invalid_grant', async () => { + const api = nock('https://api.freshbooks.com'); + api.get('/auth/api/v1/users/me').reply(401); + api.post('/auth/oauth/token').reply(400, { error: 'invalid_grant' }); + const module = createModule(); + + await expect(module.api.getUserDetails()).rejects.toMatchObject({ + statusCode: 401, + }); + + expect( + module.credentialRepository.updateAuthenticationStatus + ).toHaveBeenCalledWith('credential-1', false); + expect( + module.credentialRepository.upsertCredential + ).not.toHaveBeenCalled(); + }); + + describe('rotating single-use refresh tokens under concurrency', () => { + it('shares one refresh between concurrent 401s on the same instance', async () => { + const api = nock('https://api.freshbooks.com'); + api.get('/auth/api/v1/users/me') + .times(2) + .matchHeader('authorization', 'Bearer old-access') + .reply(401); + const tokenCall = api + .post( + '/auth/oauth/token', + (body) => body.refresh_token === 'old-refresh' + ) + .reply(200, TOKEN_RESPONSE); + api.get('/auth/api/v1/users/me') + .times(2) + .matchHeader('authorization', 'Bearer new-access') + .reply(200, { response: USER }); + const credentialRepository = createCredentialStore(); + const module = createModule(credentialRepository); + + const results = await Promise.all([ + module.api.getUserDetails(), + module.api.getUserDetails(), + ]); + + expect(results).toEqual([USER, USER]); + expect(tokenCall.isDone()).toBe(true); + expect(nock.pendingMocks()).toEqual([]); + expect(credentialRepository.upsertCredential).toHaveBeenCalledTimes( + 1 + ); + expect(credentialRepository.upsertCredential).toHaveBeenCalledWith( + expect.objectContaining({ + details: expect.objectContaining({ + refresh_token: 'new-refresh', + }), + }) + ); + }); + + describe('two instances sharing one stored credential', () => { + const ROTATED_BY_A = { + access_token: 'access-A', + refresh_token: 'refresh-A', + expires_in: 43200, + }; + + const rotateWithInstanceA = async (credentialRepository) => { + nock('https://api.freshbooks.com') + .post( + '/auth/oauth/token', + (body) => body.refresh_token === 'old-refresh' + ) + .reply(200, ROTATED_BY_A); + const instanceA = createModule(credentialRepository); + await instanceA.api.refreshAccessToken({ + refresh_token: 'old-refresh', + }); + }; + + it('adopts the winner credential after invalid_grant and retries without invalidating', async () => { + const credentialRepository = createCredentialStore(); + await rotateWithInstanceA(credentialRepository); + const instanceB = createModule(credentialRepository, [1]); + credentialRepository.findCredentialById.mockResolvedValueOnce({ + id: 'credential-1', + ...STORED_CREDENTIAL, + }); + const api = nock('https://api.freshbooks.com'); + api.get('/auth/api/v1/users/me') + .matchHeader('authorization', 'Bearer old-access') + .reply(401); + api.post( + '/auth/oauth/token', + (body) => body.refresh_token === 'old-refresh' + ).reply(400, { error: 'invalid_grant' }); + api.get('/auth/api/v1/users/me') + .matchHeader('authorization', 'Bearer access-A') + .reply(200, { response: USER }); + + const details = await instanceB.api.getUserDetails(); + + expect(details).toEqual(USER); + expect(instanceB.api.access_token).toBe('access-A'); + expect(instanceB.api.refresh_token).toBe('refresh-A'); + expect( + credentialRepository.updateAuthenticationStatus + ).not.toHaveBeenCalled(); + expect( + credentialRepository.upsertCredential + ).toHaveBeenCalledTimes(1); + expect(nock.pendingMocks()).toEqual([]); + }); + + it('adopts the winner credential before spending its own refresh when the write is already visible', async () => { + const credentialRepository = createCredentialStore(); + await rotateWithInstanceA(credentialRepository); + const instanceB = createModule(credentialRepository); + const api = nock('https://api.freshbooks.com'); + api.get('/auth/api/v1/users/me') + .matchHeader('authorization', 'Bearer old-access') + .reply(401); + api.get('/auth/api/v1/users/me') + .matchHeader('authorization', 'Bearer access-A') + .reply(200, { response: USER }); + + const details = await instanceB.api.getUserDetails(); + + expect(details).toEqual(USER); + expect(instanceB.api.refresh_token).toBe('refresh-A'); + expect( + credentialRepository.updateAuthenticationStatus + ).not.toHaveBeenCalled(); + expect(nock.pendingMocks()).toEqual([]); + }); + }); + }); +});