diff --git a/.changeset/funny-horses-destroy.md b/.changeset/funny-horses-destroy.md new file mode 100644 index 0000000..61e9dd8 --- /dev/null +++ b/.changeset/funny-horses-destroy.md @@ -0,0 +1,5 @@ +--- +"saleor-app-authorize-net": minor +--- + +Added registering and handling Authorize.net webhooks. Enclosing the two-way synchronization between Saleor and Authorize.net transactions. Known issues: hard-coded channels, skipped webhook body verification. Both will be addressed in the next release. diff --git a/.env.example b/.env.example index ae89046..48e2886 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,5 @@ AUTHORIZE_TRANSACTION_KEY= AUTHORIZE_PUBLIC_CLIENT_KEY= AUTHORIZE_ENVIRONMENT="sandbox" AUTHORIZE_SALEOR_CHANNEL_SLUG="default-channel" -AUTHORIZE_PAYMENT_FORM_URL="" \ No newline at end of file +AUTHORIZE_PAYMENT_FORM_URL="" +APP_API_BASE_URL="" diff --git a/README.md b/README.md index 7c917fe..7bb1abd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,35 @@ -Important: +# Authorize.net App -- Checkout UI relies on "Authorize transactions instead of charging" in the Saleor Dashboard -> Configuration -> Channels -> [channel] settings. +## Development + +### Build + +```bash +pnpm run build +``` + +### Run + +#### Running the Authorize.net App + +```bash +pnpm run dev +``` + +The app will run on port 8000. + +#### Running the Authorize.net UI example + +```bash +cd example +pnpm run dev +``` + +> [!IMPORTANT] +> Both the example and the app need to be [tunneled](https://docs.saleor.io/docs/3.x/developer/extending/apps/developing-with-tunnels). + +## Important + +- The `example` Checkout UI relies on the "Authorize transactions instead of charging" setting in the Saleor Dashboard -> Configuration -> Channels -> [channel] settings. + +- The Saleor transaction id is stored in Authorize transaction `order.description`. Ideally, we would store it in `refId` but that field is max. 20 characters long and the Saleor transaction id is longer than that. diff --git a/example/package.json b/example/package.json index 873a365..18974d1 100644 --- a/example/package.json +++ b/example/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --port 8001", "build": "next build", "start": "next start", "lint": "next lint --fix --dir src", diff --git a/graphql/fragments/Transaction.graphql b/graphql/fragments/Transaction.graphql new file mode 100644 index 0000000..df5e4e0 --- /dev/null +++ b/graphql/fragments/Transaction.graphql @@ -0,0 +1,22 @@ +fragment Transaction on TransactionItem { + id + pspReference + sourceObject: order { + ... on Order { + total { + gross { + ...Money + } + } + } + ...OrderOrCheckoutLines + } + privateMetadata { + key + value + } + authorizedAmount { + amount + currency + } +} diff --git a/graphql/fragments/TransactionCancelationRequestedEvent.graphql b/graphql/fragments/TransactionCancelationRequestedEvent.graphql index d47c3fa..cab19f0 100644 --- a/graphql/fragments/TransactionCancelationRequestedEvent.graphql +++ b/graphql/fragments/TransactionCancelationRequestedEvent.graphql @@ -8,21 +8,6 @@ fragment TransactionCancelationRequestedEvent on TransactionCancelationRequested amount } transaction { - id - pspReference - sourceObject: order { - channel { - id - slug - } - } - privateMetadata { - key - value - } - authorizedAmount { - amount - currency - } + ...Transaction } } diff --git a/graphql/fragments/TransactionChargeRequestedEvent.graphql b/graphql/fragments/TransactionChargeRequestedEvent.graphql index a8d3a18..1f316b8 100644 --- a/graphql/fragments/TransactionChargeRequestedEvent.graphql +++ b/graphql/fragments/TransactionChargeRequestedEvent.graphql @@ -8,17 +8,6 @@ fragment TransactionChargeRequestedEvent on TransactionChargeRequested { actionType } transaction { - id - pspReference - sourceObject: order { - ... on Order { - total { - gross { - ...Money - } - } - } - ...OrderOrCheckoutLines - } + ...Transaction } } diff --git a/graphql/fragments/TransactionInitializeSessionEvent.graphql b/graphql/fragments/TransactionInitializeSessionEvent.graphql index 03d96a9..9cebaa9 100644 --- a/graphql/fragments/TransactionInitializeSessionEvent.graphql +++ b/graphql/fragments/TransactionInitializeSessionEvent.graphql @@ -16,7 +16,7 @@ fragment TransactionInitializeSessionEvent on TransactionInitializeSession { } } transaction { - id + ...Transaction } sourceObject { __typename diff --git a/graphql/fragments/TransactionProcessSessionEvent.graphql b/graphql/fragments/TransactionProcessSessionEvent.graphql index da65873..6d91c99 100644 --- a/graphql/fragments/TransactionProcessSessionEvent.graphql +++ b/graphql/fragments/TransactionProcessSessionEvent.graphql @@ -11,7 +11,7 @@ fragment TransactionProcessSessionEvent on TransactionProcessSession { actionType } transaction { - id + ...Transaction } sourceObject { __typename diff --git a/graphql/fragments/TransactionRefundRequestedEvent.graphql b/graphql/fragments/TransactionRefundRequestedEvent.graphql index 96897cd..40e5b07 100644 --- a/graphql/fragments/TransactionRefundRequestedEvent.graphql +++ b/graphql/fragments/TransactionRefundRequestedEvent.graphql @@ -8,21 +8,6 @@ fragment TransactionRefundRequestedEvent on TransactionRefundRequested { actionType } transaction { - id - pspReference - sourceObject: order { - ... on Order { - total { - gross { - ...Money - } - } - } - ...OrderOrCheckoutLines - } - privateMetadata { - key - value - } + ...Transaction } } diff --git a/graphql/mutations/TransactionEventReport.graphql b/graphql/mutations/TransactionEventReport.graphql index 99c9ad9..f9a319f 100644 --- a/graphql/mutations/TransactionEventReport.graphql +++ b/graphql/mutations/TransactionEventReport.graphql @@ -2,10 +2,10 @@ mutation TransactionEventReport( $transactionId: ID! $amount: PositiveDecimal! $availableActions: [TransactionActionEnum!]! - $externalUrl: String! + $externalUrl: String $message: String $pspReference: String! - $time: DateTime! + $time: DateTime $type: TransactionEventTypeEnum! ) { transactionEventReport( diff --git a/package.json b/package.json index 24e1051..0bd316b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ }, "type": "module", "scripts": { - "dev": "pnpm generate && next dev", + "dev": "pnpm generate && next dev --port 8000", "build": "pnpm generate && next build", "deploy": "tsx ./src/deploy.ts", "start": "next start", @@ -63,6 +63,7 @@ "jose": "4.14.4", "jsdom": "22.1.0", "lodash-es": "4.17.21", + "micro": "10.0.1", "modern-errors": "6.0.0", "modern-errors-http": "4.0.0", "modern-errors-serialize": "5.0.0", @@ -108,7 +109,7 @@ "@types/authorizenet": "1.0.1", "@types/bluebird": "3.5.38", "@types/lodash-es": "4.17.8", - "@types/node": "20.5.4", + "@types/node": "20.10.4", "@types/omit-deep-lodash": "1.1.1", "@types/react": "18.2.21", "@types/react-dom": "18.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30b7a00..237d375 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - overrides: '@urql/exchange-auth>@urql/core': 3.2.2 @@ -52,7 +48,7 @@ dependencies: version: 1.13.0 '@vanilla-extract/next-plugin': specifier: 2.3.0 - version: 2.3.0(@types/node@20.5.4)(next@13.4.19)(webpack@5.88.2) + version: 2.3.0(@types/node@20.10.4)(next@13.4.19)(webpack@5.88.2) '@vanilla-extract/recipes': specifier: 0.5.0 version: 0.5.0(@vanilla-extract/css@1.13.0) @@ -92,6 +88,9 @@ dependencies: lodash-es: specifier: 4.17.21 version: 4.17.21 + micro: + specifier: 10.0.1 + version: 10.0.1 modern-errors: specifier: 6.0.0 version: 6.0.0 @@ -139,7 +138,7 @@ dependencies: version: 0.6.2 vite: specifier: 4.4.9 - version: 4.4.9(@types/node@20.5.4) + version: 4.4.9(@types/node@20.10.4) vitest: specifier: 0.34.2 version: 0.34.2(jsdom@22.1.0) @@ -165,7 +164,7 @@ devDependencies: version: 5.0.0(graphql@16.8.1) '@graphql-codegen/cli': specifier: 5.0.0 - version: 5.0.0(@types/node@20.5.4)(graphql@16.8.1) + version: 5.0.0(@types/node@20.10.4)(graphql@16.8.1) '@graphql-codegen/introspection': specifier: 4.0.0 version: 4.0.0(graphql@16.8.1) @@ -224,8 +223,8 @@ devDependencies: specifier: 4.17.8 version: 4.17.8 '@types/node': - specifier: 20.5.4 - version: 20.5.4 + specifier: 20.10.4 + version: 20.10.4 '@types/omit-deep-lodash': specifier: 1.1.1 version: 1.1.1 @@ -249,7 +248,7 @@ devDependencies: version: 6.4.1(eslint@8.47.0)(typescript@5.1.6) '@vanilla-extract/vite-plugin': specifier: 3.9.0 - version: 3.9.0(@types/node@20.5.4)(ts-node@10.9.1)(vite@4.4.9) + version: 3.9.0(@types/node@20.10.4)(ts-node@10.9.1)(vite@4.4.9) '@vitest/coverage-v8': specifier: 0.34.2 version: 0.34.2(vitest@0.34.2) @@ -297,7 +296,7 @@ devDependencies: version: 0.11.0(@pollyjs/core@6.0.6) ts-node: specifier: 10.9.1 - version: 10.9.1(@types/node@20.5.4)(typescript@5.1.6) + version: 10.9.1(@types/node@20.10.4)(typescript@5.1.6) typescript: specifier: 5.1.6 version: 5.1.6 @@ -1811,7 +1810,7 @@ packages: tslib: 2.5.3 dev: true - /@graphql-codegen/cli@5.0.0(@types/node@20.5.4)(graphql@16.8.1): + /@graphql-codegen/cli@5.0.0(@types/node@20.10.4)(graphql@16.8.1): resolution: {integrity: sha512-A7J7+be/a6e+/ul2KI5sfJlpoqeqwX8EzktaKCeduyVKgOLA6W5t+NUGf6QumBDXU8PEOqXk3o3F+RAwCWOiqA==} hasBin: true peerDependencies: @@ -1829,12 +1828,12 @@ packages: '@graphql-tools/apollo-engine-loader': 8.0.0(graphql@16.8.1) '@graphql-tools/code-file-loader': 8.0.2(graphql@16.8.1) '@graphql-tools/git-loader': 8.0.2(graphql@16.8.1) - '@graphql-tools/github-loader': 8.0.0(@types/node@20.5.4)(graphql@16.8.1) + '@graphql-tools/github-loader': 8.0.0(@types/node@20.10.4)(graphql@16.8.1) '@graphql-tools/graphql-file-loader': 8.0.0(graphql@16.8.1) '@graphql-tools/json-file-loader': 8.0.0(graphql@16.8.1) '@graphql-tools/load': 8.0.0(graphql@16.8.1) - '@graphql-tools/prisma-loader': 8.0.1(@types/node@20.5.4)(graphql@16.8.1) - '@graphql-tools/url-loader': 8.0.0(@types/node@20.5.4)(graphql@16.8.1) + '@graphql-tools/prisma-loader': 8.0.1(@types/node@20.10.4)(graphql@16.8.1) + '@graphql-tools/url-loader': 8.0.0(@types/node@20.10.4)(graphql@16.8.1) '@graphql-tools/utils': 10.0.5(graphql@16.8.1) '@whatwg-node/fetch': 0.8.8 chalk: 4.1.2 @@ -1842,7 +1841,7 @@ packages: debounce: 1.2.1 detect-indent: 6.1.0 graphql: 16.8.1 - graphql-config: 5.0.2(@types/node@20.5.4)(graphql@16.8.1) + graphql-config: 5.0.2(@types/node@20.10.4)(graphql@16.8.1) inquirer: 8.2.6 is-glob: 4.0.3 jiti: 1.19.3 @@ -2126,7 +2125,7 @@ packages: - utf-8-validate dev: true - /@graphql-tools/executor-http@1.0.2(@types/node@20.5.4)(graphql@16.8.1): + /@graphql-tools/executor-http@1.0.2(@types/node@20.10.4)(graphql@16.8.1): resolution: {integrity: sha512-JKTB4E3kdQM2/1NEcyrVPyQ8057ZVthCV5dFJiKktqY9IdmF00M8gupFcW3jlbM/Udn78ickeUBsUzA3EouqpA==} engines: {node: '>=16.0.0'} peerDependencies: @@ -2137,7 +2136,7 @@ packages: '@whatwg-node/fetch': 0.9.9 extract-files: 11.0.0 graphql: 16.8.1 - meros: 1.3.0(@types/node@20.5.4) + meros: 1.3.0(@types/node@20.10.4) tslib: 2.6.2 value-or-promise: 1.0.12 transitivePeerDependencies: @@ -2192,14 +2191,14 @@ packages: - supports-color dev: true - /@graphql-tools/github-loader@8.0.0(@types/node@20.5.4)(graphql@16.8.1): + /@graphql-tools/github-loader@8.0.0(@types/node@20.10.4)(graphql@16.8.1): resolution: {integrity: sha512-VuroArWKcG4yaOWzV0r19ElVIV6iH6UKDQn1MXemND0xu5TzrFme0kf3U9o0YwNo0kUYEk9CyFM0BYg4he17FA==} engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: '@ardatan/sync-fetch': 0.0.1 - '@graphql-tools/executor-http': 1.0.2(@types/node@20.5.4)(graphql@16.8.1) + '@graphql-tools/executor-http': 1.0.2(@types/node@20.10.4)(graphql@16.8.1) '@graphql-tools/graphql-tag-pluck': 8.0.2(graphql@16.8.1) '@graphql-tools/utils': 10.0.5(graphql@16.8.1) '@whatwg-node/fetch': 0.9.9 @@ -2312,13 +2311,13 @@ packages: tslib: 2.6.2 dev: true - /@graphql-tools/prisma-loader@8.0.1(@types/node@20.5.4)(graphql@16.8.1): + /@graphql-tools/prisma-loader@8.0.1(@types/node@20.10.4)(graphql@16.8.1): resolution: {integrity: sha512-bl6e5sAYe35Z6fEbgKXNrqRhXlCJYeWKBkarohgYA338/SD9eEhXtg3Cedj7fut3WyRLoQFpHzfiwxKs7XrgXg==} engines: {node: '>=16.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 dependencies: - '@graphql-tools/url-loader': 8.0.0(@types/node@20.5.4)(graphql@16.8.1) + '@graphql-tools/url-loader': 8.0.0(@types/node@20.10.4)(graphql@16.8.1) '@graphql-tools/utils': 10.0.5(graphql@16.8.1) '@types/js-yaml': 4.0.5 '@types/json-stable-stringify': 1.0.34 @@ -2387,7 +2386,7 @@ packages: value-or-promise: 1.0.12 dev: true - /@graphql-tools/url-loader@8.0.0(@types/node@20.5.4)(graphql@16.8.1): + /@graphql-tools/url-loader@8.0.0(@types/node@20.10.4)(graphql@16.8.1): resolution: {integrity: sha512-rPc9oDzMnycvz+X+wrN3PLrhMBQkG4+sd8EzaFN6dypcssiefgWKToXtRKI8HHK68n2xEq1PyrOpkjHFJB+GwA==} engines: {node: '>=16.0.0'} peerDependencies: @@ -2396,7 +2395,7 @@ packages: '@ardatan/sync-fetch': 0.0.1 '@graphql-tools/delegate': 10.0.2(graphql@16.8.1) '@graphql-tools/executor-graphql-ws': 1.1.0(graphql@16.8.1) - '@graphql-tools/executor-http': 1.0.2(@types/node@20.5.4)(graphql@16.8.1) + '@graphql-tools/executor-http': 1.0.2(@types/node@20.10.4)(graphql@16.8.1) '@graphql-tools/executor-legacy-ws': 1.0.1(graphql@16.8.1) '@graphql-tools/utils': 10.0.5(graphql@16.8.1) '@graphql-tools/wrap': 10.0.0(graphql@16.8.1) @@ -4128,7 +4127,7 @@ packages: resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} dependencies: '@types/minimatch': 5.1.2 - '@types/node': 20.5.4 + '@types/node': 20.10.4 dev: true /@types/is-ci@3.0.0: @@ -4175,8 +4174,10 @@ packages: /@types/node@12.20.55: resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - /@types/node@20.5.4: - resolution: {integrity: sha512-Y9vbIAoM31djQZrPYjpTLo0XlaSwOIsrlfE3LpulZeRblttsLQRFRlBAppW0LOxyT3ALj2M5vU1ucQQayQH3jA==} + /@types/node@20.10.4: + resolution: {integrity: sha512-D08YG6rr8X90YB56tSIuBaddy/UXAA9RKJoFvrsnogAum/0pmjkgi4+2nx96A330FmioegBWmEYQ+syqCFaveg==} + dependencies: + undici-types: 5.26.5 /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} @@ -4213,7 +4214,7 @@ packages: /@types/set-cookie-parser@2.4.3: resolution: {integrity: sha512-7QhnH7bi+6KAhBB+Auejz1uV9DHiopZqu7LfR/5gZZTkejJV5nYeZZpgfFoE0N8aDsXuiYpfKyfyMatCwQhyTQ==} dependencies: - '@types/node': 20.5.4 + '@types/node': 20.10.4 dev: true /@types/setup-polly-jest@0.5.2: @@ -4227,7 +4228,7 @@ packages: /@types/ws@8.5.5: resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} dependencies: - '@types/node': 20.5.4 + '@types/node': 20.10.4 dev: true /@typescript-eslint/eslint-plugin@6.4.1(@typescript-eslint/parser@6.4.1)(eslint@8.47.0)(typescript@5.1.6): @@ -4476,7 +4477,7 @@ packages: media-query-parser: 2.0.2 outdent: 0.8.0 - /@vanilla-extract/integration@6.2.2(@types/node@20.5.4): + /@vanilla-extract/integration@6.2.2(@types/node@20.10.4): resolution: {integrity: sha512-gV3qPFjFap1+IrPeuFy+tEZOq7l7ifJf1ik/kluDWhPr1ffsFG9puq1/jjJ4rod1BIC79Q5ZWPNvBInHyxfCew==} dependencies: '@babel/core': 7.22.10 @@ -4490,8 +4491,8 @@ packages: lodash: 4.17.21 mlly: 1.4.0 outdent: 0.8.0 - vite: 4.4.9(@types/node@20.5.4) - vite-node: 0.28.5(@types/node@20.5.4) + vite: 4.4.9(@types/node@20.10.4) + vite-node: 0.28.5(@types/node@20.10.4) transitivePeerDependencies: - '@types/node' - less @@ -4502,12 +4503,12 @@ packages: - supports-color - terser - /@vanilla-extract/next-plugin@2.3.0(@types/node@20.5.4)(next@13.4.19)(webpack@5.88.2): + /@vanilla-extract/next-plugin@2.3.0(@types/node@20.10.4)(next@13.4.19)(webpack@5.88.2): resolution: {integrity: sha512-l7ZzcL1G9zuJTAhBbOy6NKW/FPDOhDzfUXPhFGSTN1sdtO7qBmGLtOGFZATAYboAuIWXhGgnrfkyNgQh2adXZw==} peerDependencies: next: '>=12.1.7' dependencies: - '@vanilla-extract/webpack-plugin': 2.3.0(@types/node@20.5.4)(webpack@5.88.2) + '@vanilla-extract/webpack-plugin': 2.3.0(@types/node@20.10.4)(webpack@5.88.2) browserslist: 4.21.10 next: 13.4.19(@babel/core@7.22.10)(react-dom@18.2.0)(react@18.2.0) transitivePeerDependencies: @@ -4533,16 +4534,16 @@ packages: '@vanilla-extract/css': 1.13.0 dev: false - /@vanilla-extract/vite-plugin@3.9.0(@types/node@20.5.4)(ts-node@10.9.1)(vite@4.4.9): + /@vanilla-extract/vite-plugin@3.9.0(@types/node@20.10.4)(ts-node@10.9.1)(vite@4.4.9): resolution: {integrity: sha512-Q2HYAyEJ93Uy7GHQPPiP8SXwPMHGpd4d0YnrIQqB0YZccYbGJR/WFIln9Dmbzx2pdngQUoOfhwEg6kJF8sQrog==} peerDependencies: vite: ^2.2.3 || ^3.0.0 || ^4.0.3 dependencies: - '@vanilla-extract/integration': 6.2.2(@types/node@20.5.4) + '@vanilla-extract/integration': 6.2.2(@types/node@20.10.4) outdent: 0.8.0 postcss: 8.4.28 postcss-load-config: 3.1.4(postcss@8.4.28)(ts-node@10.9.1) - vite: 4.4.9(@types/node@20.5.4) + vite: 4.4.9(@types/node@20.10.4) transitivePeerDependencies: - '@types/node' - less @@ -4555,12 +4556,12 @@ packages: - ts-node dev: true - /@vanilla-extract/webpack-plugin@2.3.0(@types/node@20.5.4)(webpack@5.88.2): + /@vanilla-extract/webpack-plugin@2.3.0(@types/node@20.10.4)(webpack@5.88.2): resolution: {integrity: sha512-c+oaozLGNu+dqLNattJ9nVmy6t2OZw6qEW0xJkPS4bRXlpMSNrPwkKB1Lpov2yd2/eDuxTFi760zTZygwFNBVA==} peerDependencies: webpack: ^4.30.0 || ^5.20.2 dependencies: - '@vanilla-extract/integration': 6.2.2(@types/node@20.5.4) + '@vanilla-extract/integration': 6.2.2(@types/node@20.10.4) chalk: 4.1.2 debug: 4.3.4 loader-utils: 2.0.4 @@ -4586,7 +4587,7 @@ packages: '@babel/plugin-transform-react-jsx-self': 7.22.5(@babel/core@7.22.10) '@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.22.10) react-refresh: 0.14.0 - vite: 4.4.9(@types/node@20.5.4) + vite: 4.4.9(@types/node@20.10.4) transitivePeerDependencies: - supports-color dev: false @@ -4974,6 +4975,10 @@ packages: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} dev: true + /arg@4.1.0: + resolution: {integrity: sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==} + dev: false + /arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} dev: true @@ -5365,6 +5370,11 @@ packages: dependencies: streamsearch: 1.1.0 + /bytes@3.1.0: + resolution: {integrity: sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==} + engines: {node: '>= 0.8'} + dev: false + /bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -5684,6 +5694,11 @@ packages: safe-buffer: 5.2.1 dev: true + /content-type@1.0.4: + resolution: {integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==} + engines: {node: '>= 0.6'} + dev: false + /content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} @@ -5935,6 +5950,11 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + /depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + dev: false + /depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -6671,7 +6691,7 @@ packages: dependencies: '@typescript-eslint/utils': 6.4.1(eslint@8.47.0)(typescript@5.1.6) eslint: 8.47.0 - vite: 4.4.9(@types/node@20.5.4) + vite: 4.4.9(@types/node@20.10.4) vitest: 0.34.2(jsdom@22.1.0) transitivePeerDependencies: - supports-color @@ -6803,7 +6823,7 @@ packages: resolution: {integrity: sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==} engines: {node: '>= 0.8'} dependencies: - '@types/node': 20.5.4 + '@types/node': 20.10.4 require-like: 0.1.2 /event-emitter@0.3.5: @@ -7348,7 +7368,7 @@ packages: /graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - /graphql-config@5.0.2(@types/node@20.5.4)(graphql@16.8.1): + /graphql-config@5.0.2(@types/node@20.10.4)(graphql@16.8.1): resolution: {integrity: sha512-7TPxOrlbiG0JplSZYCyxn2XQtqVhXomEjXUmWJVSS5ET1nPhOJSsIb/WTwqWhcYX6G0RlHXSj9PLtGTKmxLNGg==} engines: {node: '>= 16.0.0'} peerDependencies: @@ -7362,7 +7382,7 @@ packages: '@graphql-tools/json-file-loader': 8.0.0(graphql@16.8.1) '@graphql-tools/load': 8.0.0(graphql@16.8.1) '@graphql-tools/merge': 9.0.0(graphql@16.8.1) - '@graphql-tools/url-loader': 8.0.0(@types/node@20.5.4)(graphql@16.8.1) + '@graphql-tools/url-loader': 8.0.0(@types/node@20.10.4)(graphql@16.8.1) '@graphql-tools/utils': 10.0.5(graphql@16.8.1) cosmiconfig: 8.2.0 graphql: 16.8.1 @@ -7498,6 +7518,17 @@ packages: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: true + /http-errors@1.7.3: + resolution: {integrity: sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==} + engines: {node: '>= 0.6'} + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.1.1 + statuses: 1.5.0 + toidentifier: 1.0.0 + dev: false + /http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -8037,7 +8068,7 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 20.5.4 + '@types/node': 20.10.4 merge-stream: 2.0.0 supports-color: 8.1.1 dev: false @@ -8567,7 +8598,7 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - /meros@1.3.0(@types/node@20.5.4): + /meros@1.3.0(@types/node@20.10.4): resolution: {integrity: sha512-2BNGOimxEz5hmjUG2FwoxCt5HN7BXdaWyFqEwxPTrJzVdABtrL4TiHTcsWSFAxPQ/tOnEaQEJh3qWq71QRMY+w==} engines: {node: '>=13'} peerDependencies: @@ -8576,7 +8607,7 @@ packages: '@types/node': optional: true dependencies: - '@types/node': 20.5.4 + '@types/node': 20.10.4 dev: true /methods@1.1.2: @@ -8584,6 +8615,16 @@ packages: engines: {node: '>= 0.6'} dev: true + /micro@10.0.1: + resolution: {integrity: sha512-9uwZSsUrqf6+4FLLpiPj5TRWQv5w5uJrJwsx1LR/TjqvQmKC1XnGQ9OHrFwR3cbZ46YqPqxO/XJCOpWnqMPw2Q==} + engines: {node: '>= 16.0.0'} + hasBin: true + dependencies: + arg: 4.1.0 + content-type: 1.0.4 + raw-body: 2.4.1 + dev: false + /micromatch@4.0.5: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} engines: {node: '>=8.6'} @@ -9328,7 +9369,7 @@ packages: dependencies: lilconfig: 2.1.0 postcss: 8.4.28 - ts-node: 10.9.1(@types/node@20.5.4)(typescript@5.1.6) + ts-node: 10.9.1(@types/node@20.10.4)(typescript@5.1.6) yaml: 1.10.2 dev: true @@ -9521,6 +9562,16 @@ packages: engines: {node: '>= 0.6'} dev: true + /raw-body@2.4.1: + resolution: {integrity: sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==} + engines: {node: '>= 0.8'} + dependencies: + bytes: 3.1.0 + http-errors: 1.7.3 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + dev: false + /raw-body@2.5.1: resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==} engines: {node: '>= 0.8'} @@ -10136,6 +10187,10 @@ packages: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} dev: true + /setprototypeof@1.1.1: + resolution: {integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==} + dev: false + /setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -10339,6 +10394,11 @@ packages: type-fest: 0.7.1 dev: false + /statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + dev: false + /statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -10644,6 +10704,11 @@ packages: dependencies: is-number: 7.0.0 + /toidentifier@1.0.0: + resolution: {integrity: sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==} + engines: {node: '>=0.6'} + dev: false + /toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -10695,7 +10760,7 @@ packages: resolution: {integrity: sha512-PGcnJoTBnVGy6yYNFxWVNkdcAuAMstvutN9MgDJIV6L0oG8fB+ZNNy1T+wJzah8RPGor1mZuPQkVfXNDpy9eHA==} dev: true - /ts-node@10.9.1(@types/node@20.5.4)(typescript@5.1.6): + /ts-node@10.9.1(@types/node@20.10.4)(typescript@5.1.6): resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true peerDependencies: @@ -10714,7 +10779,7 @@ packages: '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.5.4 + '@types/node': 20.10.4 acorn: 8.10.0 acorn-walk: 8.2.0 arg: 4.1.3 @@ -10946,6 +11011,9 @@ packages: engines: {node: '>=0.10.0'} dev: true + /undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + /universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -11146,7 +11214,7 @@ packages: extsprintf: 1.3.0 dev: false - /vite-node@0.28.5(@types/node@20.5.4): + /vite-node@0.28.5(@types/node@20.10.4): resolution: {integrity: sha512-LmXb9saMGlrMZbXTvOveJKwMTBTNUH66c8rJnQ0ZPNX+myPEol64+szRzXtV5ORb0Hb/91yq+/D3oERoyAt6LA==} engines: {node: '>=v14.16.0'} hasBin: true @@ -11158,7 +11226,7 @@ packages: picocolors: 1.0.0 source-map: 0.6.1 source-map-support: 0.5.21 - vite: 4.4.9(@types/node@20.5.4) + vite: 4.4.9(@types/node@20.10.4) transitivePeerDependencies: - '@types/node' - less @@ -11169,7 +11237,7 @@ packages: - supports-color - terser - /vite-node@0.34.2(@types/node@20.5.4): + /vite-node@0.34.2(@types/node@20.10.4): resolution: {integrity: sha512-JtW249Zm3FB+F7pQfH56uWSdlltCo1IOkZW5oHBzeQo0iX4jtC7o1t9aILMGd9kVekXBP2lfJBEQt9rBh07ebA==} engines: {node: '>=v14.18.0'} hasBin: true @@ -11179,7 +11247,7 @@ packages: mlly: 1.4.0 pathe: 1.1.1 picocolors: 1.0.0 - vite: 4.4.9(@types/node@20.5.4) + vite: 4.4.9(@types/node@20.10.4) transitivePeerDependencies: - '@types/node' - less @@ -11201,13 +11269,13 @@ packages: debug: 4.3.4 globrex: 0.1.2 tsconfck: 2.1.2(typescript@5.1.6) - vite: 4.4.9(@types/node@20.5.4) + vite: 4.4.9(@types/node@20.10.4) transitivePeerDependencies: - supports-color - typescript dev: true - /vite@4.4.9(@types/node@20.5.4): + /vite@4.4.9(@types/node@20.10.4): resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -11235,7 +11303,7 @@ packages: terser: optional: true dependencies: - '@types/node': 20.5.4 + '@types/node': 20.10.4 esbuild: 0.18.20 postcss: 8.4.28 rollup: 3.28.1 @@ -11275,7 +11343,7 @@ packages: dependencies: '@types/chai': 4.3.5 '@types/chai-subset': 1.3.3 - '@types/node': 20.5.4 + '@types/node': 20.10.4 '@vitest/expect': 0.34.2 '@vitest/runner': 0.34.2 '@vitest/snapshot': 0.34.2 @@ -11295,8 +11363,8 @@ packages: strip-literal: 1.3.0 tinybench: 2.5.0 tinypool: 0.7.0 - vite: 4.4.9(@types/node@20.5.4) - vite-node: 0.34.2(@types/node@20.5.4) + vite: 4.4.9(@types/node@20.10.4) + vite-node: 0.34.2(@types/node@20.10.4) why-is-node-running: 2.2.2 transitivePeerDependencies: - less @@ -11686,3 +11754,7 @@ packages: react: 18.2.0 use-sync-external-store: 1.2.0(react@18.2.0) dev: false + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false diff --git a/src/errors.ts b/src/errors.ts index 45259ce..cc03834 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,6 +1,7 @@ import { type TRPC_ERROR_CODE_KEY } from "@trpc/server/rpc"; import ModernError from "modern-errors"; import ModernErrorsSerialize from "modern-errors-serialize"; +import { ZodError } from "zod"; // Http errors type CommonProps = { @@ -49,3 +50,11 @@ export const ReqMissingTokenError = BaseTrpcError.subclass("ReqMissingTokenError export const ReqMissingAppIdError = BaseTrpcError.subclass("ReqMissingAppIdError", { props: { trpcCode: "BAD_REQUEST" } as TrpcErrorOptions, }); + +export function normalizeError(error: unknown) { + if (error instanceof ZodError) { + return BaseError.normalize(error.format()); + } + + return BaseError.normalize(error); +} diff --git a/src/lib/env.mjs b/src/lib/env.mjs index 0c8f939..ffe1ace 100644 --- a/src/lib/env.mjs +++ b/src/lib/env.mjs @@ -19,34 +19,36 @@ export const env = createEnv({ .optional() .default("error"), VERCEL_URL: z.string().optional(), + APP_API_BASE_URL: z.string().optional(), PORT: z.coerce.number().optional(), UPSTASH_URL: z.string().optional(), UPSTASH_TOKEN: z.string().optional(), REST_APL_ENDPOINT: z.string().optional(), REST_APL_TOKEN: z.string().optional(), - // The following variables should be back to "optional" once the app features adding config through the UI. AUTHORIZE_API_LOGIN_ID: z .string() .min(1) - // .optional() .describe("API Login ID. You can find it in Account → API Credentials & Keys."), AUTHORIZE_TRANSACTION_KEY: z .string() .min(1) - // .optional() .describe( "Transaction key needed to authenticate Authorize.net SDK. You can generate it in Account → API Credentials & Keys.", ), + AUTHORIZE_SIGNATURE_KEY: z + .string() + .min(1) + .describe( + "Signature Key needed to verify webhooks. You can generate it in Account → API Credentials & Keys.", + ), AUTHORIZE_PUBLIC_CLIENT_KEY: z .string() .min(1) - // .optional() .describe("Public client key. You can generate it in Account -> Manage Public Client Key"), - AUTHORIZE_ENVIRONMENT: z.enum(["sandbox", "production"]) /*.optional()*/, + AUTHORIZE_ENVIRONMENT: z.enum(["sandbox", "production"]), AUTHORIZE_SALEOR_CHANNEL_SLUG: z .string() .min(1) - // .optional() .default("default-channel") .describe( "Saleor channel slug. When configuring the app through env, you can only use one channel.", @@ -54,7 +56,6 @@ export const env = createEnv({ AUTHORIZE_PAYMENT_FORM_URL: z .string() .min(1) - // .optional() .describe( "Payment form URL. This is the address your front-end UI is running on. Make sure it is on https. Otherwise the Accept Hosted form will not work.", ), @@ -84,6 +85,7 @@ export const env = createEnv({ CI: process.env.CI, APP_DEBUG: process.env.APP_DEBUG, VERCEL_URL: process.env.VERCEL_URL, + APP_API_BASE_URL: process.env.APP_API_BASE_URL, PORT: process.env.PORT, UPSTASH_URL: process.env.UPSTASH_URL, UPSTASH_TOKEN: process.env.UPSTASH_TOKEN, @@ -91,6 +93,7 @@ export const env = createEnv({ REST_APL_TOKEN: process.env.REST_APL_TOKEN, AUTHORIZE_API_LOGIN_ID: process.env.AUTHORIZE_API_LOGIN_ID, AUTHORIZE_TRANSACTION_KEY: process.env.AUTHORIZE_TRANSACTION_KEY, + AUTHORIZE_SIGNATURE_KEY: process.env.AUTHORIZE_SIGNATURE_KEY, AUTHORIZE_PUBLIC_CLIENT_KEY: process.env.AUTHORIZE_PUBLIC_CLIENT_KEY, AUTHORIZE_ENVIRONMENT: process.env.AUTHORIZE_ENVIRONMENT, AUTHORIZE_SALEOR_CHANNEL_SLUG: process.env.AUTHORIZE_SALEOR_CHANNEL_SLUG, diff --git a/src/modules/authorize-net/authorize-net-config.ts b/src/modules/authorize-net/authorize-net-config.ts index a20cb02..2adf6cb 100644 --- a/src/modules/authorize-net/authorize-net-config.ts +++ b/src/modules/authorize-net/authorize-net-config.ts @@ -2,11 +2,40 @@ import { z } from "zod"; export const authorizeEnvironmentSchema = z.enum(["sandbox", "production"]); +export const authorizeNetEventSchema = z.enum([ + "net.authorize.payment.authorization.created", + "net.authorize.payment.authcapture.created", + "net.authorize.payment.capture.created", + "net.authorize.payment.refund.created", + "net.authorize.payment.priorAuthCapture.created", + "net.authorize.payment.void.created", +]); + +export type AuthorizeNetEvent = z.infer; + +const webhookInputSchema = z.object({ + url: z.string(), + eventTypes: z.array(authorizeNetEventSchema), + status: z.enum(["active", "inactive"]), +}); + +export type AuthorizeNetWebhookInput = z.infer; + +export const webhookSchema = webhookInputSchema.and( + z.object({ + webhookId: z.string(), + }), +); + +export type AuthorizeNetWebhook = z.infer; + const inputSchema = z.object({ apiLoginId: z.string().min(1), publicClientKey: z.string().min(1), transactionKey: z.string().min(1), + signatureKey: z.string().min(1), environment: authorizeEnvironmentSchema, + webhook: webhookSchema.nullable(), }); const fullSchema = inputSchema.extend({ diff --git a/src/modules/authorize-net/authorize-net-error.ts b/src/modules/authorize-net/authorize-net-error.ts index 032d93b..a1b58d8 100644 --- a/src/modules/authorize-net/authorize-net-error.ts +++ b/src/modules/authorize-net/authorize-net-error.ts @@ -5,3 +5,7 @@ export const AuthorizeNetError = BaseError.subclass("AuthorizeNetError"); export const AuthorizeNetCreateTransactionError = AuthorizeNetError.subclass( "AuthorizeNetCreateTransactionError", ); + +export const AuthorizeNetInvalidWebhookSignatureError = AuthorizeNetError.subclass( + "AuthorizeNetInvalidWebhookSignatureError", +); diff --git a/src/modules/authorize-net/client/create-transaction.ts b/src/modules/authorize-net/client/create-transaction.ts index 0b1f5c8..5a9f294 100644 --- a/src/modules/authorize-net/client/create-transaction.ts +++ b/src/modules/authorize-net/client/create-transaction.ts @@ -8,12 +8,8 @@ const ApiControllers = AuthorizeNet.APIControllers; const createTransactionSchema = baseAuthorizeObjectSchema.and(z.unknown()); -type CreateTransactionResponse = z.infer; - export class CreateTransactionClient extends AuthorizeNetClient { - async createTransaction( - transactionInput: AuthorizeNet.APIContracts.TransactionRequestType, - ): Promise { + async createTransaction(transactionInput: AuthorizeNet.APIContracts.TransactionRequestType) { const createRequest = new ApiContracts.CreateTransactionRequest(); createRequest.setMerchantAuthentication(this.merchantAuthenticationType); createRequest.setTransactionRequest(transactionInput); diff --git a/src/modules/authorize-net/client/hosted-payment-page-client.ts b/src/modules/authorize-net/client/hosted-payment-page-client.ts index f09f4e0..782a59f 100644 --- a/src/modules/authorize-net/client/hosted-payment-page-client.ts +++ b/src/modules/authorize-net/client/hosted-payment-page-client.ts @@ -1,4 +1,3 @@ -import { env } from "process"; import AuthorizeNet from "authorizenet"; import { z } from "zod"; @@ -16,46 +15,17 @@ const getHostedPaymentPageResponseSchema = baseAuthorizeObjectSchema.and( export type GetHostedPaymentPageResponse = z.infer; export class HostedPaymentPageClient extends AuthorizeNetClient { - private getHostedPaymentPageSettings(): AuthorizeNet.APIContracts.ArrayOfSetting { - const settings = { - hostedPaymentReturnOptions: { - showReceipt: false, // must be false if we want to receive the transaction response in the payment form iframe - }, - hostedPaymentIFrameCommunicatorUrl: { - url: `${env.AUTHORIZE_PAYMENT_FORM_URL}/accept-hosted.html`, // url where the payment form iframe will be hosted, - }, - hostedPaymentCustomerOptions: { - showEmail: false, - requiredEmail: false, - addPaymentProfile: true, - }, - }; - - const settingsArray: AuthorizeNet.APIContracts.SettingType[] = []; - - Object.entries(settings).forEach(([settingName, settingValue]) => { - const setting = new ApiContracts.SettingType(); - setting.setSettingName(settingName); - setting.setSettingValue(JSON.stringify(settingValue)); - settingsArray.push(setting); - }); - - const arrayOfSettings = new ApiContracts.ArrayOfSetting(); - arrayOfSettings.setSetting(settingsArray); - - return arrayOfSettings; - } - - async getHostedPaymentPageRequest( - transactionInput: AuthorizeNet.APIContracts.TransactionRequestType, - ): Promise { + async getHostedPaymentPageRequest({ + transactionInput, + settingsInput, + }: { + transactionInput: AuthorizeNet.APIContracts.TransactionRequestType; + settingsInput: AuthorizeNet.APIContracts.ArrayOfSetting; + }): Promise { const createRequest = new ApiContracts.GetHostedPaymentPageRequest(); createRequest.setMerchantAuthentication(this.merchantAuthenticationType); createRequest.setTransactionRequest(transactionInput); - - const settings = this.getHostedPaymentPageSettings(); - - createRequest.setHostedPaymentSettings(settings); + createRequest.setHostedPaymentSettings(settingsInput); const transactionController = new ApiControllers.GetHostedPaymentPageController( createRequest.getJSON(), @@ -67,7 +37,7 @@ export class HostedPaymentPageClient extends AuthorizeNetClient { transactionController.execute(() => { try { this.logger.debug( - { settings }, + { settings: settingsInput }, "Calling getHostedPaymentPageRequest with the following settings:", ); diff --git a/src/modules/authorize-net/client/transaction-details-client.ts b/src/modules/authorize-net/client/transaction-details-client.ts index e8b47e8..42d8fa6 100644 --- a/src/modules/authorize-net/client/transaction-details-client.ts +++ b/src/modules/authorize-net/client/transaction-details-client.ts @@ -9,9 +9,14 @@ const ApiControllers = AuthorizeNet.APIControllers; const getTransactionDetailsSchema = baseAuthorizeObjectSchema.and( z.object({ transaction: z.object({ + transId: z.string().min(1), transactionStatus: z.string().min(1), authAmount: z.number(), responseReasonDescription: z.string().min(1), + submitTimeLocal: z.string().min(1), + order: z.object({ + description: z.string().min(1), + }), }), }), ); diff --git a/src/modules/authorize-net/synchronized-transaction/create-synchronized-transaction-request.ts b/src/modules/authorize-net/synchronized-transaction/create-synchronized-transaction-request.ts new file mode 100644 index 0000000..10bc00e --- /dev/null +++ b/src/modules/authorize-net/synchronized-transaction/create-synchronized-transaction-request.ts @@ -0,0 +1,32 @@ +import AuthorizeNet from "authorizenet"; + +export const ApiContracts = AuthorizeNet.APIContracts; + +/** + * + * @description This function creates a transaction request that will include information needed to connect a Saleor transaction with an Authorize.net transaction. + * The saleorTransactionId is stored in the description field of the Authorize.net transaction. This is a hack. Unfortunately, no other field is fitting because the + * `refId` field is max. 20 characters long. + */ + +export function createSynchronizedTransactionRequest({ + saleorTransactionId, + authorizeTransactionId, +}: { + saleorTransactionId: string; + authorizeTransactionId?: string; +}) { + const transactionRequest = new ApiContracts.TransactionRequestType(); + + if (authorizeTransactionId) { + // refTransId is the transaction ID of the original transaction being referenced + transactionRequest.setRefTransId(authorizeTransactionId); + } + + const order = new ApiContracts.OrderType(); + order.setDescription(saleorTransactionId); + + transactionRequest.setOrder(order); + + return transactionRequest; +} diff --git a/src/modules/authorize-net/synchronized-transaction/saleor-transaction-id-converter.ts b/src/modules/authorize-net/synchronized-transaction/saleor-transaction-id-converter.ts new file mode 100644 index 0000000..26c870b --- /dev/null +++ b/src/modules/authorize-net/synchronized-transaction/saleor-transaction-id-converter.ts @@ -0,0 +1,21 @@ +import { type GetTransactionDetailsResponse } from "../client/transaction-details-client"; +import { type TransactionFragment } from "generated/graphql"; + +/** + * We need to pass the saleorTransactionId to Authorize.net transaction so that we can + * later (in the Authorize → Saleor webhook) match the Authorize.net transaction with the Saleor transaction. + * + * The logical way to do it would be by using the `refId` field, but it's limited to 20 characters. Saleor transaction id is longer than that. + * Thus, why we use the `order.description` field, which is limited to 255 characters. + * + * `transactionIdConverter` makes sure the format of the string is the same on both sides. + */ +export const saleorTransactionIdConverter = { + fromSaleorTransaction(saleorTransaction: TransactionFragment) { + return btoa(saleorTransaction.id); // we need to encode the string to base64, because Authorize.net can't parse the "=" character that is in the Saleor transaction ID + }, + fromAuthorizeNetTransaction(authorizeTransaction: GetTransactionDetailsResponse) { + const orderDescription = authorizeTransaction.transaction.order.description; // we need to decode it back to use the Saleor transaction ID + return atob(orderDescription); + }, +}; diff --git a/src/modules/authorize-net/synchronized-transaction/synchronized-transaction-id-resolver.ts b/src/modules/authorize-net/synchronized-transaction/synchronized-transaction-id-resolver.ts new file mode 100644 index 0000000..0a6f45a --- /dev/null +++ b/src/modules/authorize-net/synchronized-transaction/synchronized-transaction-id-resolver.ts @@ -0,0 +1,41 @@ +import { type Client } from "urql"; +import { TransactionMetadataManager } from "../../configuration/transaction-metadata-manager"; +import { type MetadataItem, type TransactionFragment } from "generated/graphql"; +import { BaseError } from "@/errors"; + +export const TransactionIdResolverError = BaseError.subclass("TransactionIdResolverError"); + +// The term "synchronized transaction" is used to describe a logic that is used to synchronize transaction between Saleor and Authorize.net. +export class SynchronizedTransactionIdResolver { + constructor(private apiClient: Client) {} + + private async getTransactionIdFromMetadata({ metadata }: { metadata: readonly MetadataItem[] }) { + const metadataManager = new TransactionMetadataManager({ apiClient: this.apiClient }); + const transactionId = await metadataManager.getAuthorizeTransactionId({ metadata }); + + return transactionId; + } + + async resolveFromTransaction(transaction: TransactionFragment) { + const saleorTransactionId = transaction?.id; + + if (!saleorTransactionId) { + throw new TransactionIdResolverError("Missing saleorTransactionId in payload"); + } + + const metadata = transaction.privateMetadata; + + if (!metadata) { + throw new TransactionIdResolverError("Missing metadata in payload"); + } + + const authorizeTransactionId = await this.getTransactionIdFromMetadata({ + metadata, + }); + + return { + authorizeTransactionId, + saleorTransactionId, + }; + } +} diff --git a/src/modules/authorize-net/webhook/authorize-net-webhook-client.ts b/src/modules/authorize-net/webhook/authorize-net-webhook-client.ts new file mode 100644 index 0000000..4296d6e --- /dev/null +++ b/src/modules/authorize-net/webhook/authorize-net-webhook-client.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; +import { createAuthorizeWebhooksFetch } from "./create-authorize-webhooks-fetch"; +import { createLogger } from "@/lib/logger"; +import { + webhookSchema, + type AuthorizeProviderConfig, + type AuthorizeNetWebhookInput, +} from "@/modules/authorize-net/authorize-net-config"; + +const webhookResponseSchema = z + .object({ + _links: z.object({ + self: z.object({ + href: z.string(), + }), + }), + }) + .and(webhookSchema); + +/** + * @description Authorize.net has a separate API for registering webhooks. This class communicates with that API. + * @see AuthorizeNetClient for managing transactions etc. + */ +export class AuthorizeNetWebhookClient { + private fetch: ReturnType; + + private logger = createLogger({ + name: "AuthorizeNetWebhookClient", + }); + + constructor(config: AuthorizeProviderConfig.FullShape) { + this.fetch = createAuthorizeWebhooksFetch(config); + } + + async registerWebhook(input: AuthorizeNetWebhookInput) { + const response = await this.fetch({ + method: "POST", + body: input, + }); + + const result = await response.json(); + + this.logger.trace({ result }, "registerWebhook response:"); + + const parsedResult = webhookResponseSchema.parse(result); + + return parsedResult; + } +} diff --git a/src/modules/authorize-net/webhook/authorize-net-webhook-errors.ts b/src/modules/authorize-net/webhook/authorize-net-webhook-errors.ts new file mode 100644 index 0000000..628cf18 --- /dev/null +++ b/src/modules/authorize-net/webhook/authorize-net-webhook-errors.ts @@ -0,0 +1,7 @@ +import { BaseError } from "@/errors"; + +const AuthorizeNetWebhookError = BaseError.subclass("AuthorizeNetWebhookError", {}); + +export const MissingAuthDataError = AuthorizeNetWebhookError.subclass("MissingAuthDataError", {}); + +export const MissingAppUrlError = AuthorizeNetWebhookError.subclass("MissingAppUrlError", {}); diff --git a/src/modules/authorize-net/webhook/authorize-net-webhook-handler.ts b/src/modules/authorize-net/webhook/authorize-net-webhook-handler.ts new file mode 100644 index 0000000..7428a75 --- /dev/null +++ b/src/modules/authorize-net/webhook/authorize-net-webhook-handler.ts @@ -0,0 +1,149 @@ +import crypto from "crypto"; +import { type AuthData } from "@saleor/app-sdk/APL"; +import { buffer } from "micro"; +import { type NextApiRequest } from "next"; +import { z } from "zod"; +import { authorizeNetEventSchema, type AuthorizeProviderConfig } from "../authorize-net-config"; +import { AuthorizeNetInvalidWebhookSignatureError } from "../authorize-net-error"; +import { MissingAuthDataError } from "./authorize-net-webhook-errors"; +import { AuthorizeNetWebhookTransactionSynchronizer } from "./authorize-net-webhook-transaction-synchronizer"; +import { saleorApp } from "@/saleor-app"; +import { resolveAuthorizeConfigFromAppConfig } from "@/modules/configuration/authorize-config-resolver"; +import { AppConfigMetadataManager } from "@/modules/configuration/app-config-metadata-manager"; +import { createLogger } from "@/lib/logger"; +import { createServerClient } from "@/lib/create-graphq-client"; + +const eventPayloadSchema = z.object({ + notificationId: z.string(), + eventType: authorizeNetEventSchema, + eventDate: z.string(), + webhookId: z.string(), + payload: z.object({ + entityName: z.enum(["transaction"]), + id: z.string(), + }), +}); + +export type EventPayload = z.infer; + +/** + * @description This class is used to handle webhook calls from Authorize.net + */ +export class AuthorizeNetWebhookHandler { + private authorizeSignature = "x-anet-signature"; + private authData: AuthData | null = null; + private authorizeConfig: AuthorizeProviderConfig.FullShape | null = null; + + private logger = createLogger({ + name: "AuthorizeWebhookHandler", + }); + + constructor(private request: NextApiRequest) {} + + private async getAuthData() { + if (this.authData) { + return this.authData; + } + + const results = await saleorApp.apl.getAll(); + const authData = results?.[0]; + + if (!authData) { + throw new MissingAuthDataError("APL not found"); + } + + this.authData = authData; + + return authData; + } + + private async getRawBody() { + const rawBody = (await buffer(this.request)).toString(); + return rawBody; + } + + /** + * @description This method follows the process described in the documentation: + * @see https://developer.authorize.net/api/reference/features/webhooks.html#Verifying_the_Notification + */ + private async verifyWebhook() { + this.logger.debug("Verifying webhook signature..."); + const authorizeConfig = await this.getAuthorizeConfig(); + const headers = this.request.headers; + const xAnetSignature = headers[this.authorizeSignature]?.toString(); + + if (!xAnetSignature) { + throw new AuthorizeNetInvalidWebhookSignatureError( + `Missing ${this.authorizeSignature} header`, + ); + } + + const rawBody = await this.getRawBody(); + + const hash = crypto + .createHmac("sha512", authorizeConfig.signatureKey) + .update(rawBody) + .digest("hex"); + + const validSignature = `sha512=${hash.toUpperCase()}`; + + if (validSignature !== xAnetSignature) { + throw new AuthorizeNetInvalidWebhookSignatureError("The signature does not match"); + } + + this.logger.debug("Webhook verified successfully"); + } + + private async parseWebhookBody() { + const rawBody = await this.getRawBody(); + const body = JSON.parse(rawBody); + const parseResult = eventPayloadSchema.safeParse(body); + + if (!parseResult.success) { + throw new AuthorizeNetInvalidWebhookSignatureError("Unexpected shape of the webhook body"); + } + + const eventPayload = parseResult.data; + + return eventPayload; + } + + private async getAuthorizeConfig() { + if (this.authorizeConfig) { + return this.authorizeConfig; + } + + const authData = await this.getAuthData(); + const channelSlug = "default-channel"; // todo: get rid of channelSlug + + const appConfigMetadataManager = AppConfigMetadataManager.createFromAuthData(authData); + const appConfigurator = await appConfigMetadataManager.get(); + const appConfig = appConfigurator.rootData; + + const authorizeConfig = resolveAuthorizeConfigFromAppConfig({ appConfig, channelSlug }); + this.authorizeConfig = authorizeConfig; + + return authorizeConfig; + } + + private async processAuthorizeWebhook(eventPayload: EventPayload) { + const authData = await this.getAuthData(); + const authorizeConfig = await this.getAuthorizeConfig(); + + const client = createServerClient(authData.saleorApiUrl, authData.token); + + const synchronizer = new AuthorizeNetWebhookTransactionSynchronizer({ + client, + authorizeConfig, + }); + return synchronizer.synchronizeTransaction(eventPayload); + } + + async handle() { + await this.verifyWebhook(); + const eventPayload = await this.parseWebhookBody(); + await this.processAuthorizeWebhook(eventPayload); + + this.logger.info("Finished processing webhook"); + } +} diff --git a/src/modules/authorize-net/webhook/authorize-net-webhook-manager.ts b/src/modules/authorize-net/webhook/authorize-net-webhook-manager.ts new file mode 100644 index 0000000..3537512 --- /dev/null +++ b/src/modules/authorize-net/webhook/authorize-net-webhook-manager.ts @@ -0,0 +1,100 @@ +import { type AuthData } from "@saleor/app-sdk/APL"; +import { env } from "../../../lib/env.mjs"; +import { isDevelopment } from "../../../lib/isEnv"; +import { AppConfigMetadataManager } from "../../configuration/app-config-metadata-manager"; +import { AppConfigurator, type AppConfig } from "../../configuration/app-configurator"; +import { resolveAuthorizeConfigFromAppConfig } from "../../configuration/authorize-config-resolver"; +import { + type AuthorizeNetWebhook, + type AuthorizeNetWebhookInput, + type AuthorizeProviderConfig, +} from "../authorize-net-config"; +import { AuthorizeNetWebhookClient } from "./authorize-net-webhook-client"; +import { MissingAppUrlError } from "./authorize-net-webhook-errors"; +import { createLogger } from "@/lib/logger"; + +/** + * @description This class is used to register and manage the webhook with Authorize.net + */ +export class AuthorizeWebhookManager { + private authData: AuthData; + private appConfig: AppConfig.Shape; + + private authorizeConfig: AuthorizeProviderConfig.FullShape; + + private logger = createLogger({ + name: "AuthorizeWebhookManager", + }); + + constructor({ + authData, + appConfig, + channelSlug, + }: { + authData: AuthData; + appConfig: AppConfig.Shape; + channelSlug: string; + }) { + this.authData = authData; + this.appConfig = appConfig; + + this.authorizeConfig = resolveAuthorizeConfigFromAppConfig({ + appConfig, + channelSlug, + }); + } + + private async updateMetadataWithWebhook(webhook: AuthorizeNetWebhook) { + const nextConfig: AuthorizeProviderConfig.FullShape = { + ...this.authorizeConfig, + webhook, + }; + + const appConfigMetadataManager = AppConfigMetadataManager.createFromAuthData(this.authData); + const appConfigurator = new AppConfigurator(this.appConfig); + + appConfigurator.providers.updateProvider(nextConfig); + await appConfigMetadataManager.set(appConfigurator); + } + + private getWebhookParams() { + const appUrl = isDevelopment() ? env.APP_API_BASE_URL : `https://${env.VERCEL_URL}`; // todo: get rid of it + + if (!appUrl) { + throw new MissingAppUrlError("Missing appUrl needed for registering the webhook"); + } + + const url = new URL("/api/webhooks/authorize", appUrl); + + const webhookParams: AuthorizeNetWebhookInput = { + eventTypes: [ + "net.authorize.payment.capture.created", + "net.authorize.payment.priorAuthCapture.created", + "net.authorize.payment.void.created", + "net.authorize.payment.refund.created", + ], + status: "active", + url: url.href, + }; + + return webhookParams; + } + + public async register() { + if (this.authorizeConfig.webhook) { + this.logger.info("Webhook already registered"); + return; + } + + this.logger.debug("Registering webhook..."); + + const webhooksClient = new AuthorizeNetWebhookClient(this.authorizeConfig); + + const webhookParams = this.getWebhookParams(); + const webhook = await webhooksClient.registerWebhook(webhookParams); + + await this.updateMetadataWithWebhook(webhook); + + this.logger.info("Webhook registered successfully"); + } +} diff --git a/src/modules/authorize-net/webhook/authorize-net-webhook-transaction-synchronizer.ts b/src/modules/authorize-net/webhook/authorize-net-webhook-transaction-synchronizer.ts new file mode 100644 index 0000000..fdd10e4 --- /dev/null +++ b/src/modules/authorize-net/webhook/authorize-net-webhook-transaction-synchronizer.ts @@ -0,0 +1,82 @@ +import { type Client } from "urql"; +import { type AuthorizeProviderConfig, type AuthorizeNetEvent } from "../authorize-net-config"; +import { TransactionDetailsClient } from "../client/transaction-details-client"; +import { AuthorizeNetError } from "../authorize-net-error"; +import { type EventPayload } from "./authorize-net-webhook-handler"; +import { + type TransactionEventReportMutation, + type TransactionEventReportMutationVariables, + TransactionEventReportDocument, + TransactionEventTypeEnum, +} from "generated/graphql"; +import { saleorTransactionIdConverter } from "@/modules/authorize-net/synchronized-transaction/saleor-transaction-id-converter"; + +const TransactionEventReportMutationError = AuthorizeNetError.subclass( + "TransactionEventReportMutationError", +); + +/** + * @description This class is used to synchronize Authorize.net transactions with Saleor transactions + */ +export class AuthorizeNetWebhookTransactionSynchronizer { + private authorizeConfig: AuthorizeProviderConfig.FullShape; + private client: Client; + + constructor({ + authorizeConfig, + client, + }: { + authorizeConfig: AuthorizeProviderConfig.FullShape; + client: Client; + }) { + this.authorizeConfig = authorizeConfig; + this.client = client; + } + + private mapEventType(authorizeTransactionType: AuthorizeNetEvent): TransactionEventTypeEnum { + switch (authorizeTransactionType) { + // todo: + default: + return TransactionEventTypeEnum.AuthorizationActionRequired; + } + } + + private async transactionEventReport(variables: TransactionEventReportMutationVariables) { + const { error: mutationError } = await this.client + .mutation(TransactionEventReportDocument, variables) + .toPromise(); + + if (mutationError) { + throw new TransactionEventReportMutationError( + "Error while mapping the transaction in the authorize webhook handler.", + { cause: mutationError.message }, + ); + } + } + + private getAuthorizeTransaction({ id }: { id: string }) { + const transactionDetailsClient = new TransactionDetailsClient(this.authorizeConfig); + return transactionDetailsClient.getTransactionDetailsRequest({ transactionId: id }); + } + + async synchronizeTransaction(eventPayload: EventPayload) { + const transactionId = eventPayload.payload.id; + const authorizeTransaction = await this.getAuthorizeTransaction({ id: transactionId }); + + const saleorTransactionId = + saleorTransactionIdConverter.fromAuthorizeNetTransaction(authorizeTransaction); + + const authorizeTransactionId = authorizeTransaction.transaction.transId; + + const type = this.mapEventType(eventPayload.eventType); + + await this.transactionEventReport({ + amount: authorizeTransaction.transaction.authAmount, + availableActions: [], + pspReference: authorizeTransactionId, + time: authorizeTransaction.transaction.submitTimeLocal, + transactionId: saleorTransactionId, + type, + }); + } +} diff --git a/src/modules/authorize-net/webhook/create-authorize-webhooks-fetch.ts b/src/modules/authorize-net/webhook/create-authorize-webhooks-fetch.ts new file mode 100644 index 0000000..73ab6f5 --- /dev/null +++ b/src/modules/authorize-net/webhook/create-authorize-webhooks-fetch.ts @@ -0,0 +1,47 @@ +import { createLogger } from "@/lib/logger"; +import { type AuthorizeProviderConfig } from "@/modules/authorize-net/authorize-net-config"; + +/** + * @description Create a value for Authorization: basic header for Authorize.net webhooks + * @see https://developer.authorize.net/api/reference/features/webhooks.html + */ +function createAuthorizeAuthenticationKey(config: AuthorizeProviderConfig.FullShape): string { + const concatenatedKey = `${config.apiLoginId}:${config.transactionKey}`; + const encodedKey = Buffer.from(concatenatedKey).toString("base64"); + + return encodedKey; +} + +type AuthorizeWebhooksFetchParams = { + path?: string; + body?: Record; + method: Required; +} & Omit; + +export function createAuthorizeWebhooksFetch(config: AuthorizeProviderConfig.FullShape) { + const authenticationKey = createAuthorizeAuthenticationKey(config); + + const url = + config.environment === "sandbox" + ? "https://apitest.authorize.net/rest/v1/webhooks" + : "https://api.authorize.net/rest/v1/webhooks"; + + const logger = createLogger({ + name: "AuthorizeWebhooksFetch", + }); + + return ({ path, body, method }: AuthorizeWebhooksFetchParams) => { + const apiUrl = path ? `${url}/${path}` : url; + const options = { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${authenticationKey}`, + }, + body: JSON.stringify(body), + }; + + logger.trace({ apiUrl, options: { method, body } }, "Calling Authorize.net webhooks API"); + return fetch(apiUrl, options); + }; +} diff --git a/src/modules/channel-connection/channel-connection-configurator.test.ts b/src/modules/channel-connection/channel-connection-configurator.test.ts deleted file mode 100644 index f1f44b3..0000000 --- a/src/modules/channel-connection/channel-connection-configurator.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { type AppConfig } from "../configuration/app-configurator"; -import { ChannelConnectionConfigurator } from "./channel-connection-configurator"; - -let rootData: AppConfig.Shape["connections"] = []; - -beforeEach(() => { - rootData = []; -}); - -describe("ChannelConnectionConfigurator", () => { - describe("getConnections", () => { - it("returns all connections", () => { - const configurator = new ChannelConnectionConfigurator([ - { - id: "1", - channelSlug: "channel-slug-1", - providerId: "provider-id-1", - }, - { - id: "2", - channelSlug: "channel-slug-2", - providerId: "provider-id-2", - }, - ]); - - expect(configurator.getConnections()).toEqual([ - { - id: "1", - channelSlug: "channel-slug-1", - providerId: "provider-id-1", - }, - { - id: "2", - channelSlug: "channel-slug-2", - providerId: "provider-id-2", - }, - ]); - }); - }); - - describe("addConnection", () => { - it("adds a connection", () => { - const configurator = new ChannelConnectionConfigurator(rootData); - - configurator.addConnection({ - channelSlug: "channel-slug", - providerId: "provider-id", - }); - - expect(configurator.getConnections()).toEqual([ - { - id: expect.any(String), - channelSlug: "channel-slug", - providerId: "provider-id", - }, - ]); - }); - }); - - describe("updateConnection", () => { - it("updates a connection", () => { - const configurator = new ChannelConnectionConfigurator([ - { - id: "1", - channelSlug: "channel-slug-1", - providerId: "provider-id-1", - }, - ]); - - configurator.updateConnection({ - id: "1", - channelSlug: "channel-slug-2", - providerId: "provider-id-2", - }); - - expect(configurator.getConnections()).toEqual([ - { - id: "1", - channelSlug: "channel-slug-2", - providerId: "provider-id-2", - }, - ]); - }); - - it("doesn't update a connection if the id doesn't match", () => { - const configurator = new ChannelConnectionConfigurator([ - { - id: "1", - channelSlug: "channel-slug-1", - providerId: "provider-id-1", - }, - ]); - - configurator.updateConnection({ - id: "2", - channelSlug: "channel-slug-2", - providerId: "provider-id-2", - }); - - expect(configurator.getConnections()).toEqual([ - { - id: "1", - channelSlug: "channel-slug-1", - providerId: "provider-id-1", - }, - ]); - }); - }); - - describe("deleteConnection", () => { - it("deletes a connection", () => { - const configurator = new ChannelConnectionConfigurator([ - { - id: "1", - channelSlug: "channel-slug-1", - providerId: "provider-id-1", - }, - { - id: "2", - channelSlug: "channel-slug-2", - providerId: "provider-id-2", - }, - ]); - - configurator.deleteConnection("1"); - - expect(configurator.getConnections()).toEqual([ - { - id: "2", - channelSlug: "channel-slug-2", - providerId: "provider-id-2", - }, - ]); - }); - - it("doesn't delete a connection if the id doesn't match", () => { - const configurator = new ChannelConnectionConfigurator([ - { - id: "1", - channelSlug: "channel-slug-1", - providerId: "provider-id-1", - }, - { - id: "2", - channelSlug: "channel-slug-2", - providerId: "provider-id-2", - }, - ]); - - configurator.deleteConnection("3"); - - expect(configurator.getConnections()).toEqual([ - { - id: "1", - channelSlug: "channel-slug-1", - providerId: "provider-id-1", - }, - { - id: "2", - channelSlug: "channel-slug-2", - providerId: "provider-id-2", - }, - ]); - }); - }); -}); diff --git a/src/modules/channel-connection/channel-connection-configurator.ts b/src/modules/channel-connection/channel-connection-configurator.ts deleted file mode 100644 index 2647682..0000000 --- a/src/modules/channel-connection/channel-connection-configurator.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { type AppConfig } from "../configuration/app-configurator"; -import { ChannelConnection } from "./channel-connection.schema"; -import { generateId } from "@/lib/generate-id"; - -type Connections = AppConfig.Shape["connections"]; - -export class ChannelConnectionConfigurator { - private connections: Connections = []; - - constructor(connections: Connections) { - this.connections = connections; - } - - getConnections() { - return this.connections; - } - - addConnection(input: ChannelConnection.InputShape) { - const connectionConfig = ChannelConnection.Schema.Input.parse(input); - - this.connections.push({ - ...connectionConfig, - id: generateId(), - }); - - return this; - } - - updateConnection(connection: ChannelConnection.FullShape) { - const parsedConfig = ChannelConnection.Schema.Full.parse(connection); - - this.connections = this.connections.map((p) => { - if (p.id === parsedConfig.id) { - return parsedConfig; - } - - return p; - }); - } - - deleteConnection(connectionId: string) { - this.connections = this.connections.filter((p) => p.id !== connectionId); - } -} diff --git a/src/modules/channel-connection/channel-connection.router.ts b/src/modules/channel-connection/channel-connection.router.ts deleted file mode 100644 index 28835c1..0000000 --- a/src/modules/channel-connection/channel-connection.router.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { z } from "zod"; - -import { AppConfigMetadataManager } from "../configuration/app-config-metadata-manager"; -import { createSettingsManager } from "../configuration/settings-manager"; -import { protectedClientProcedure } from "../trpc/protected-client-procedure"; -import { router } from "../trpc/trpc-server"; -import { ChannelConnection } from "./channel-connection.schema"; - -const procedure = protectedClientProcedure.use(({ ctx, next }) => { - const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!); - - return next({ - ctx: { - settingsManager, - appConfigService: new AppConfigMetadataManager(settingsManager), - }, - }); -}); - -export const channelConnectionRouter = router({ - getAll: procedure.query(async ({ ctx: { appConfigService } }) => { - const config = await appConfigService.get(); - const providers = config.connections.getConnections(); - - return providers; - }), - addOne: procedure - .input(ChannelConnection.Schema.Input) - .mutation(async ({ ctx: { appConfigService }, input }) => { - const config = await appConfigService.get(); - - config.connections.addConnection(input); - - await appConfigService.set(config); - }), - updateOne: procedure - .input(ChannelConnection.Schema.Full) - .mutation(async ({ input, ctx: { appConfigService } }) => { - const config = await appConfigService.get(); - - config.connections.updateConnection(input); - - return appConfigService.set(config); - }), - deleteOne: procedure - .input(z.object({ id: z.string() })) - .mutation(async ({ input, ctx: { appConfigService } }) => { - const config = await appConfigService.get(); - - config.connections.deleteConnection(input.id); - - return appConfigService.set(config); - }), -}); diff --git a/src/modules/configuration/active-provider-resolver.test.ts b/src/modules/configuration/active-provider-resolver.test.ts deleted file mode 100644 index f300185..0000000 --- a/src/modules/configuration/active-provider-resolver.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ActiveProviderResolver } from "./active-provider-resolver"; -import { type AppConfig } from "./app-configurator"; - -describe("ActiveProviderResolver", () => { - describe("resolve", () => { - it("should return the provider for the given channel", () => { - const appConfig: AppConfig.Shape = { - providers: [ - { - id: "provider1", - apiLoginId: "apiLoginId1", - publicClientKey: "publicClientKey1", - transactionKey: "transactionKey1", - environment: "sandbox", - }, - { - id: "provider2", - apiLoginId: "apiLoginId2", - publicClientKey: "publicClientKey2", - transactionKey: "transactionKey2", - environment: "sandbox", - }, - ], - connections: [ - { - channelSlug: "channel1", - providerId: "provider1", - id: "connection1", - }, - { - channelSlug: "channel2", - providerId: "provider2", - id: "connection2", - }, - ], - }; - - const activeProviderResolver = new ActiveProviderResolver(appConfig); - - const provider = activeProviderResolver.resolve("channel1"); - - expect(provider).toEqual(appConfig.providers[0]); - }); - it("should throw an error if the connection is not found", () => { - const appConfig: AppConfig.Shape = { - connections: [], - providers: [], - }; - - const activeProviderResolver = new ActiveProviderResolver(appConfig); - expect(() => activeProviderResolver.resolve("channel1")).toThrow(); - }); - it("should throw an error if the provider is not found", () => { - const appConfig: AppConfig.Shape = { - connections: [ - { - channelSlug: "channel1", - providerId: "provider1", - id: "connection1", - }, - ], - providers: [], - }; - - const activeProviderResolver = new ActiveProviderResolver(appConfig); - expect(() => activeProviderResolver.resolve("channel1")).toThrow(); - }); - }); -}); diff --git a/src/modules/configuration/active-provider-resolver.ts b/src/modules/configuration/active-provider-resolver.ts deleted file mode 100644 index c39ad6d..0000000 --- a/src/modules/configuration/active-provider-resolver.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NoChannelSlugFoundError, NoConnectionFoundError, NoProviderFoundError } from "@/errors"; -import { type ChannelConnection } from "@/modules/channel-connection/channel-connection.schema"; -import { type AppConfig } from "@/modules/configuration/app-configurator"; - -export class ActiveProviderResolver { - constructor(private appConfig: AppConfig.Shape) {} - - private resolveActiveConnection(channelSlug: string) { - const channel = this.appConfig.connections.find((c) => c.channelSlug === channelSlug); - - if (!channel) { - throw new NoConnectionFoundError(`Channel ${channelSlug} not found in the connections`); - } - - return channel; - } - - private resolveProviderForConnection(connection: ChannelConnection.FullShape) { - const provider = this.appConfig.providers.find((p) => p.id === connection.providerId); - - if (!provider) { - throw new NoProviderFoundError( - `Provider ${connection.providerId} not found in the providers`, - ); - } - - return provider; - } - - public resolve(channelSlug: string | null | undefined) { - if (!channelSlug) { - throw new NoChannelSlugFoundError(`Channel ${channelSlug} not found in the connections`); - } - - const connection = this.resolveActiveConnection(channelSlug); - const provider = this.resolveProviderForConnection(connection); - - return provider; - } -} diff --git a/src/modules/configuration/app-config-resolver.test.ts b/src/modules/configuration/app-config-resolver.test.ts deleted file mode 100644 index 3257f85..0000000 --- a/src/modules/configuration/app-config-resolver.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { encrypt } from "@saleor/app-sdk/settings-manager"; -import { describe } from "vitest"; -import { type AppConfig } from "./app-configurator"; -import { env } from "@/lib/env.mjs"; -import { type WebhookRecipientFragment } from "generated/graphql"; - -const appConfig: AppConfig.Shape = { - connections: [ - { - channelSlug: "channel1", - id: "connection1", - providerId: "provider1", - }, - ], - providers: [ - { - apiLoginId: "apiLoginId1", - environment: "sandbox", - id: "provider1", - publicClientKey: "publicClientKey1", - transactionKey: "transactionKey1", - }, - ], -}; - -const _metadata: WebhookRecipientFragment["privateMetadata"] = [ - { - key: "appConfig", - value: encrypt(JSON.stringify(appConfig), env.SECRET_KEY), - }, -]; - -describe.todo("AppConfigResolver"); diff --git a/src/modules/configuration/app-config-resolver.ts b/src/modules/configuration/app-config-resolver.ts index 2c1dfcc..b4c4dbc 100644 --- a/src/modules/configuration/app-config-resolver.ts +++ b/src/modules/configuration/app-config-resolver.ts @@ -1,10 +1,11 @@ import { decrypt } from "@saleor/app-sdk/settings-manager"; -import { type AppConfigMetadataManager } from "./app-config-metadata-manager"; +import { type AuthData } from "@saleor/app-sdk/APL"; +import { AppConfigMetadataManager } from "./app-config-metadata-manager"; import { env } from "@/lib/env.mjs"; import { generateId } from "@/lib/generate-id"; import { createLogger, logger } from "@/lib/logger"; import { AppConfig, AppConfigurator } from "@/modules/configuration/app-configurator"; -import { type WebhookRecipientFragment } from "generated/graphql"; +import { type MetadataItem, type WebhookRecipientFragment } from "generated/graphql"; /** * This function looks for AppConfig in the webhook recipient metadata. If it's not found, it looks for it in the env. @@ -18,7 +19,7 @@ const defaultAppConfig: AppConfig.Shape = { providers: [], }; -export class AppConfigResolver { +class AppConfigResolver { private logger = createLogger({ name: "AppConfigResolver", }); @@ -32,6 +33,8 @@ export class AppConfigResolver { publicClientKey: env.AUTHORIZE_PUBLIC_CLIENT_KEY, transactionKey: env.AUTHORIZE_TRANSACTION_KEY, environment: env.AUTHORIZE_ENVIRONMENT, + signatureKey: env.AUTHORIZE_SIGNATURE_KEY, + webhook: null, }; const connectionInput = { @@ -91,3 +94,18 @@ export class AppConfigResolver { return envConfig; } } + +export async function resolveAppConfigFromCtx({ + authData, + appMetadata, +}: { + authData: AuthData; + appMetadata: readonly MetadataItem[]; +}) { + const appConfigMetadataManager = AppConfigMetadataManager.createFromAuthData(authData); + const appConfigResolver = new AppConfigResolver(appConfigMetadataManager); + + const appConfig = await appConfigResolver.resolve({ metadata: appMetadata }); + + return appConfig; +} diff --git a/src/modules/configuration/app-configurator.test.ts b/src/modules/configuration/app-configurator.test.ts index a509efe..0de3020 100644 --- a/src/modules/configuration/app-configurator.test.ts +++ b/src/modules/configuration/app-configurator.test.ts @@ -11,6 +11,8 @@ describe("AppConfigurator", () => { transactionKey: "transaction-key", environment: "sandbox", publicClientKey: "public-client-key", + signatureKey: "signature-key", + webhook: null, }); const parsedConfig = JSON.parse(configurator.serialize()); @@ -23,6 +25,8 @@ describe("AppConfigurator", () => { transactionKey: "transaction-key", environment: "sandbox", publicClientKey: "public-client-key", + signatureKey: "signature-key", + webhook: null, }, ], connections: [], diff --git a/src/modules/configuration/app-configurator.ts b/src/modules/configuration/app-configurator.ts index 4aec579..c8ac3a9 100644 --- a/src/modules/configuration/app-configurator.ts +++ b/src/modules/configuration/app-configurator.ts @@ -1,8 +1,7 @@ import { z } from "zod"; import { AuthorizeProviderConfig } from "../authorize-net/authorize-net-config"; -import { ChannelConnectionConfigurator } from "../channel-connection/channel-connection-configurator"; import { ChannelConnection } from "../channel-connection/channel-connection.schema"; -import { ProvidersConfigurator } from "../provider/provider-configurator"; +import { generateId } from "@/lib/generate-id"; export namespace AppConfig { export const Schema = z.object({ @@ -14,23 +13,80 @@ export namespace AppConfig { } export class AppConfigurator { - private rootData: AppConfig.Shape = { + rootData: AppConfig.Shape = { providers: [], connections: [], }; - providers: ProvidersConfigurator; - connections: ChannelConnectionConfigurator; - constructor(initialData?: AppConfig.Shape) { if (initialData) { this.rootData = initialData; } - - this.providers = new ProvidersConfigurator(this.rootData.providers); - this.connections = new ChannelConnectionConfigurator(this.rootData.connections); } + providers = { + getProviders: () => { + return this.rootData.providers; + }, + getProviderById: (id: string) => { + return this.rootData.providers.find((p) => p.id === id); + }, + addProvider: (input: AuthorizeProviderConfig.InputShape) => { + const nextProviders = [ + ...this.rootData.providers, + { + ...input, + id: generateId(), + }, + ]; + + this.rootData.providers = nextProviders; + }, + updateProvider: (provider: AuthorizeProviderConfig.FullShape) => { + const nextProviders = this.rootData.providers.map((p) => { + if (p.id === provider.id) { + return provider; + } + + return p; + }); + + this.rootData.providers = nextProviders; + }, + deleteProvider: (providerId: string) => { + const nextProviders = this.rootData.providers.filter((p) => p.id !== providerId); + + this.rootData.providers = nextProviders; + }, + }; + + connections = { + getConnections: () => { + return this.rootData.connections; + }, + addConnection: (input: ChannelConnection.InputShape) => { + const nextConnections = [...this.rootData.connections, { ...input, id: generateId() }]; + + this.rootData.connections = nextConnections; + }, + updateConnection: (connection: ChannelConnection.FullShape) => { + const nextConnections = this.rootData.connections.map((p) => { + if (p.id === connection.id) { + return connection; + } + + return p; + }); + + this.rootData.connections = nextConnections; + }, + deleteConnection: (connectionId: string) => { + const nextConnections = this.rootData.connections.filter((p) => p.id !== connectionId); + + this.rootData.connections = nextConnections; + }, + }; + static parse(serializedSchema: string) { const parsedSchema = JSON.parse(serializedSchema); const configSchema = AppConfig.Schema.parse(parsedSchema); diff --git a/src/modules/configuration/authorize-config-resolver.ts b/src/modules/configuration/authorize-config-resolver.ts new file mode 100644 index 0000000..0e0d1b3 --- /dev/null +++ b/src/modules/configuration/authorize-config-resolver.ts @@ -0,0 +1,28 @@ +import { NoChannelSlugFoundError, NoConnectionFoundError, NoProviderFoundError } from "@/errors"; +import { type AppConfig } from "@/modules/configuration/app-configurator"; + +export function resolveAuthorizeConfigFromAppConfig({ + appConfig, + channelSlug, +}: { + appConfig: AppConfig.Shape; + channelSlug: string | null | undefined; +}) { + if (!channelSlug) { + throw new NoChannelSlugFoundError(`Channel ${channelSlug} not found in the connections`); + } + + const channel = appConfig.connections.find((c) => c.channelSlug === channelSlug); + + if (!channel) { + throw new NoConnectionFoundError(`Channel ${channelSlug} not found in the connections`); + } + + const provider = appConfig.providers.find((p) => p.id === channel.providerId); + + if (!provider) { + throw new NoProviderFoundError(`Provider ${channel.providerId} not found in the providers`); + } + + return provider; +} diff --git a/src/modules/provider/provider-configurator.test.ts b/src/modules/provider/provider-configurator.test.ts deleted file mode 100644 index 680baa0..0000000 --- a/src/modules/provider/provider-configurator.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { type AppConfig } from "../configuration/app-configurator"; -import { ProvidersConfigurator } from "./provider-configurator"; - -let rootData: AppConfig.Shape["providers"] = []; - -beforeEach(() => { - rootData = []; -}); - -describe("ProvidersConfigurator", () => { - describe("getProviders", () => { - it("returns all providers", () => { - const configurator = new ProvidersConfigurator([ - { - id: "1", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - { - id: "2", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - ]); - - expect(configurator.getProviders()).toEqual([ - { - id: "1", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - { - id: "2", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - ]); - }); - }); - - describe("getProviderById", () => { - it("returns the provider with the given id", () => { - const configurator = new ProvidersConfigurator([ - { - id: "1", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - ]); - - expect(configurator.getProviderById("1")).toEqual({ - id: "1", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }); - }); - - it("returns undefined if the id doesn't match", () => { - const configurator = new ProvidersConfigurator([ - { - id: "1", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - ]); - - expect(configurator.getProviderById("not-a-real-id")).toBeUndefined(); - }); - }); - - describe("addProvider", () => { - it("adds a new provider", () => { - const configurator = new ProvidersConfigurator(rootData); - - configurator.addProvider({ - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }); - - expect(configurator.getProviders()).toEqual([ - { - id: expect.any(String), - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - ]); - }); - }); - - describe("updateProvider", () => { - it("doesn't update the provider if the id doesn't match", () => { - const configurator = new ProvidersConfigurator(rootData); - - configurator.updateProvider({ - id: "not-a-real-id", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }); - - expect(configurator.getProviders()).toEqual(rootData); - }); - it("updates the provider with the given id", () => { - const configurator = new ProvidersConfigurator([ - { - id: "1", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - ]); - - configurator.updateProvider({ - id: "1", - apiLoginId: "new-api-login-id", - transactionKey: "new-transaction-key", - environment: "sandbox", - publicClientKey: "new-public-client-key", - }); - - expect(configurator.getProviders()).toEqual([ - { - id: "1", - apiLoginId: "new-api-login-id", - transactionKey: "new-transaction-key", - environment: "sandbox", - publicClientKey: "new-public-client-key", - }, - ]); - }); - }); - - describe("deleteProvider", () => { - it("deletes the selected provider", () => { - const configurator = new ProvidersConfigurator([ - { - id: "1", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - { - id: "2", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - ]); - - configurator.deleteProvider("1"); - - expect(configurator.getProviders()).toEqual([ - { - id: "2", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - ]); - }); - - it("doesn't delete any providers if the id doesn't match", () => { - const configurator = new ProvidersConfigurator([ - { - id: "1", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - { - id: "2", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - ]); - - configurator.deleteProvider("not-a-real-id"); - - expect(configurator.getProviders()).toEqual([ - { - id: "1", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - { - id: "2", - apiLoginId: "api-login-id", - transactionKey: "transaction-key", - environment: "sandbox", - publicClientKey: "public-client-key", - }, - ]); - }); - }); -}); diff --git a/src/modules/provider/provider-configurator.ts b/src/modules/provider/provider-configurator.ts deleted file mode 100644 index 40bd481..0000000 --- a/src/modules/provider/provider-configurator.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { AuthorizeProviderConfig } from "../authorize-net/authorize-net-config"; -import { type AppConfig } from "../configuration/app-configurator"; -import { generateId } from "@/lib/generate-id"; - -type Providers = AppConfig.Shape["providers"]; - -export class ProvidersConfigurator { - constructor(private providers: Providers) {} - - getProviders() { - return this.providers; - } - - getProviderById(id: string) { - return this.providers.find((p) => p.id === id); - } - - addProvider(input: AuthorizeProviderConfig.InputShape) { - const providerConfig = AuthorizeProviderConfig.Schema.Input.parse(input); - - this.providers.push({ - ...providerConfig, - id: generateId(), - }); - - return this; - } - - updateProvider(provider: AuthorizeProviderConfig.FullShape) { - const parsedConfig = AuthorizeProviderConfig.Schema.Full.parse(provider); - - this.providers = this.providers.map((p) => { - if (p.id === parsedConfig.id) { - return parsedConfig; - } else { - return p; - } - }); - } - - deleteProvider(providerId: string) { - this.providers = this.providers.filter((p) => p.id !== providerId); - } -} diff --git a/src/modules/provider/provider.router.ts b/src/modules/provider/provider.router.ts deleted file mode 100644 index 4a5fd04..0000000 --- a/src/modules/provider/provider.router.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { z } from "zod"; - -import { AuthorizeProviderConfig } from "../authorize-net/authorize-net-config"; -import { AppConfigMetadataManager } from "../configuration/app-config-metadata-manager"; -import { createSettingsManager } from "../configuration/settings-manager"; -import { protectedClientProcedure } from "../trpc/protected-client-procedure"; -import { router } from "../trpc/trpc-server"; - -const procedure = protectedClientProcedure.use(({ ctx, next }) => { - const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!); - - return next({ - ctx: { - settingsManager, - appConfigService: new AppConfigMetadataManager(settingsManager), - }, - }); -}); - -export const providerRouter = router({ - getAll: procedure.query(async ({ ctx: { appConfigService } }) => { - const config = await appConfigService.get(); - const providers = config.providers.getProviders(); - - return providers; - }), - getOne: procedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx: { appConfigService }, input }) => { - const config = await appConfigService.get(); - - return config.providers.getProviderById(input.id) ?? null; - }), - addOne: procedure - .input(AuthorizeProviderConfig.Schema.Input) - .mutation(async ({ ctx: { appConfigService }, input }) => { - const config = await appConfigService.get(); - - config.providers.addProvider(input); - - await appConfigService.set(config); - }), - updateOne: procedure - .input(AuthorizeProviderConfig.Schema.Full) - .mutation(async ({ input, ctx: { appConfigService } }) => { - const config = await appConfigService.get(); - - config?.providers.updateProvider(input); - - return appConfigService.set(config); - }), - deleteOne: procedure - .input(z.object({ id: z.string() })) - .mutation(async ({ input, ctx: { appConfigService } }) => { - const config = await appConfigService.get(); - - config.providers.deleteProvider(input.id); - - return appConfigService.set(config); - }), -}); diff --git a/src/modules/trpc/trpc-app-router.ts b/src/modules/trpc/trpc-app-router.ts index 92e58eb..8e82f9a 100644 --- a/src/modules/trpc/trpc-app-router.ts +++ b/src/modules/trpc/trpc-app-router.ts @@ -1,10 +1,7 @@ -import { channelConnectionRouter } from "../channel-connection/channel-connection.router"; -import { providerRouter } from "../provider/provider.router"; import { router } from "./trpc-server"; export const appRouter = router({ - providers: providerRouter, - connections: channelConnectionRouter, + // no routers because we don't have client-side calls }); export type AppRouter = typeof appRouter; diff --git a/src/modules/webhooks/transaction-cancelation-requested.ts b/src/modules/webhooks/transaction-cancelation-requested.ts index cb19c98..d0d2f10 100644 --- a/src/modules/webhooks/transaction-cancelation-requested.ts +++ b/src/modules/webhooks/transaction-cancelation-requested.ts @@ -2,13 +2,12 @@ import AuthorizeNet from "authorizenet"; import { type Client } from "urql"; import { type AuthorizeProviderConfig } from "../authorize-net/authorize-net-config"; import { CreateTransactionClient } from "../authorize-net/client/create-transaction"; -import { TransactionMetadataManager } from "../configuration/transaction-metadata-manager"; -import { - type MetadataItem, - type TransactionCancelationRequestedEventFragment, -} from "generated/graphql"; +import { SynchronizedTransactionIdResolver } from "../authorize-net/synchronized-transaction/synchronized-transaction-id-resolver"; +import { createSynchronizedTransactionRequest } from "../authorize-net/synchronized-transaction/create-synchronized-transaction-request"; +import { type TransactionCancelationRequestedEventFragment } from "generated/graphql"; import { BaseError } from "@/errors"; +import { invariant } from "@/lib/invariant"; import { createLogger } from "@/lib/logger"; import { type TransactionCancelationRequestedResponse } from "@/schemas/TransactionCancelationRequested/TransactionCancelationRequestedResponse.mjs"; @@ -37,21 +36,19 @@ export class TransactionCancelationRequestedService { this.apiClient = apiClient; } - private async getTransactionIdFromMetadata({ metadata }: { metadata: readonly MetadataItem[] }) { - const metadataManager = new TransactionMetadataManager({ apiClient: this.apiClient }); - const transactionId = await metadataManager.getAuthorizeTransactionId({ metadata }); - - return transactionId; - } - private async buildTransactionFromPayload({ authorizeTransactionId, + saleorTransactionId, }: { authorizeTransactionId: string; + saleorTransactionId: string; }): Promise { - const transactionRequest = new ApiContracts.TransactionRequestType(); + const transactionRequest = createSynchronizedTransactionRequest({ + saleorTransactionId, + authorizeTransactionId, + }); + transactionRequest.setTransactionType(ApiContracts.TransactionTypeEnum.VOIDTRANSACTION); - transactionRequest.setRefTransId(authorizeTransactionId); return transactionRequest; } @@ -59,20 +56,20 @@ export class TransactionCancelationRequestedService { async execute( payload: TransactionCancelationRequestedEventFragment, ): Promise { - this.logger.debug({ id: payload.transaction?.id }, "Called execute with"); + this.logger.debug({ id: payload.transaction?.id }, "Canceling transaction"); - const saleorTransactionId = payload.transaction?.id; + invariant(payload.transaction, "Transaction is missing"); - if (!saleorTransactionId) { - throw new TransactionCancelationRequestedError("Missing saleorTransactionId in payload"); - } + const idResolver = new SynchronizedTransactionIdResolver(this.apiClient); + const { saleorTransactionId, authorizeTransactionId } = await idResolver.resolveFromTransaction( + payload.transaction, + ); - const authorizeTransactionId = await this.getTransactionIdFromMetadata({ - metadata: payload.transaction.privateMetadata ?? [], + const transactionInput = await this.buildTransactionFromPayload({ + authorizeTransactionId, + saleorTransactionId, }); - const transactionInput = await this.buildTransactionFromPayload({ authorizeTransactionId }); - const createTransactionClient = new CreateTransactionClient(this.authorizeConfig); await createTransactionClient.createTransaction(transactionInput); diff --git a/src/modules/webhooks/transaction-initialize-session.ts b/src/modules/webhooks/transaction-initialize-session.ts index 16d174b..90c163f 100644 --- a/src/modules/webhooks/transaction-initialize-session.ts +++ b/src/modules/webhooks/transaction-initialize-session.ts @@ -1,3 +1,4 @@ +import { env } from "process"; import AuthorizeNet from "authorizenet"; import { z } from "zod"; import { @@ -9,6 +10,8 @@ import { type GetHostedPaymentPageResponse, } from "../authorize-net/client/hosted-payment-page-client"; import { CustomerProfileManager } from "../customer-profile/customer-profile-manager"; +import { saleorTransactionIdConverter } from "../authorize-net/synchronized-transaction/saleor-transaction-id-converter"; +import { createSynchronizedTransactionRequest } from "../authorize-net/synchronized-transaction/create-synchronized-transaction-request"; import { type TransactionInitializeSessionEventFragment } from "generated/graphql"; import { BaseError } from "@/errors"; @@ -58,10 +61,24 @@ export class TransactionInitializeSessionService { private async buildTransactionFromPayload( payload: TransactionInitializeSessionEventFragment, ): Promise { - const transactionRequest = new ApiContracts.TransactionRequestType(); + const saleorTransactionId = saleorTransactionIdConverter.fromSaleorTransaction( + payload.transaction, + ); + + this.logger.trace({ saleorTransactionId }, "Saleor transaction id"); + + const transactionRequest = createSynchronizedTransactionRequest({ + saleorTransactionId, + }); + transactionRequest.setTransactionType(ApiContracts.TransactionTypeEnum.AUTHONLYTRANSACTION); transactionRequest.setAmount(payload.action.amount); + const order = new ApiContracts.OrderType(); + order.setDescription(saleorTransactionId); + + transactionRequest.setOrder(order); + const userEmail = payload.sourceObject.userEmail; if (!userEmail) { @@ -105,8 +122,6 @@ export class TransactionInitializeSessionService { transactionRequest.setProfile(profile); } - this.logger.trace("Finished building transaction request."); - return transactionRequest; } @@ -130,17 +145,58 @@ export class TransactionInitializeSessionService { return dataParseResult.data; } + private getHostedPaymentPageSettings(): AuthorizeNet.APIContracts.ArrayOfSetting { + const settings = { + hostedPaymentReturnOptions: { + showReceipt: false, // must be false if we want to receive the transaction response in the payment form iframe + }, + hostedPaymentIFrameCommunicatorUrl: { + url: `${env.AUTHORIZE_PAYMENT_FORM_URL}/accept-hosted.html`, // url where the payment form iframe will be hosted, + }, + hostedPaymentCustomerOptions: { + showEmail: false, + requiredEmail: false, + addPaymentProfile: true, + }, + hostedPaymentOrderOptions: { + /** we need to hide order details because we are using order.description to store the saleorTransactionId. + * @see: createSynchronizedTransactionRequest */ + show: false, + }, + }; + + const settingsArray: AuthorizeNet.APIContracts.SettingType[] = []; + + Object.entries(settings).forEach(([settingName, settingValue]) => { + const setting = new ApiContracts.SettingType(); + setting.setSettingName(settingName); + setting.setSettingValue(JSON.stringify(settingValue)); + settingsArray.push(setting); + }); + + const arrayOfSettings = new ApiContracts.ArrayOfSetting(); + arrayOfSettings.setSetting(settingsArray); + + return arrayOfSettings; + } + async execute( payload: TransactionInitializeSessionEventFragment, ): Promise { - this.logger.debug({ id: payload.transaction?.id }, "Called execute with"); + this.logger.debug( + { id: payload.transaction?.id }, + "Getting hosted payment page settings for transaction", + ); const transactionInput = await this.buildTransactionFromPayload(payload); + const settingsInput = this.getHostedPaymentPageSettings(); const hostedPaymentPageClient = new HostedPaymentPageClient(this.authorizeConfig); - const hostedPaymentPageResponse = - await hostedPaymentPageClient.getHostedPaymentPageRequest(transactionInput); + const hostedPaymentPageResponse = await hostedPaymentPageClient.getHostedPaymentPageRequest({ + transactionInput, + settingsInput, + }); this.logger.trace("Successfully called getHostedPaymentPageRequest"); diff --git a/src/modules/webhooks/transaction-process-session.ts b/src/modules/webhooks/transaction-process-session.ts index e7320a3..56baca2 100644 --- a/src/modules/webhooks/transaction-process-session.ts +++ b/src/modules/webhooks/transaction-process-session.ts @@ -1,5 +1,5 @@ -import { z } from "zod"; import { type Client } from "urql"; +import { z } from "zod"; import { type AuthorizeProviderConfig } from "../authorize-net/authorize-net-config"; import { TransactionDetailsClient, @@ -8,8 +8,8 @@ import { import { TransactionMetadataManager } from "../configuration/transaction-metadata-manager"; import { type TransactionProcessSessionEventFragment } from "generated/graphql"; import { type TransactionProcessSessionResponse } from "@/schemas/TransactionProcessSession/TransactionProcessSessionResponse.mjs"; -import { BaseError } from "@/errors"; import { createLogger } from "@/lib/logger"; +import { BaseError } from "@/errors"; export const TransactionProcessError = BaseError.subclass("TransactionProcessError"); @@ -41,9 +41,9 @@ export class TransactionProcessSessionService { } /** - * @description Saves Authorize transaction ID in metadata for future usage in other operations (e.g. `transaction-cancelation-requested`). + * @description Saves Authorize transaction ID in metadata for future usage in other operations (e.g. `transaction-cancelation-requested`). Also saves Saleor transaction ID in Authorize transaction as order.description. */ - private async saveTransactionIdInMetadata({ + private async synchronizeTransaction({ saleorTransactionId, authorizeTransactionId, }: { @@ -51,6 +51,7 @@ export class TransactionProcessSessionService { authorizeTransactionId: string; }) { const metadataManager = new TransactionMetadataManager({ apiClient: this.apiClient }); + await metadataManager.saveTransactionId({ saleorTransactionId, authorizeTransactionId, @@ -63,15 +64,24 @@ export class TransactionProcessSessionService { */ private mapTransactionToWebhookResponse( response: GetTransactionDetailsResponse, - ): Pick { + ): TransactionProcessSessionResponse { + const baseResponse: Pick< + TransactionProcessSessionResponse, + "amount" | "message" | "pspReference" + > = { + amount: response.transaction.authAmount, + message: response.transaction.responseReasonDescription, + pspReference: response.transaction.transId, + }; + const { transactionStatus } = response.transaction; if (transactionStatus === "authorizedPendingCapture") { - return { result: "AUTHORIZATION_SUCCESS", actions: ["CANCEL", "REFUND"] }; + return { ...baseResponse, result: "AUTHORIZATION_SUCCESS", actions: ["CANCEL", "REFUND"] }; } if (transactionStatus === "FDSPendingReview") { - return { result: "AUTHORIZATION_REQUEST", actions: [] }; + return { ...baseResponse, result: "AUTHORIZATION_REQUEST", actions: [] }; } throw new TransactionProcessError(`Unexpected transaction status: ${transactionStatus}`); @@ -80,7 +90,7 @@ export class TransactionProcessSessionService { async execute( payload: TransactionProcessSessionEventFragment, ): Promise { - this.logger.debug({ id: payload.transaction?.id }, "Called execute with"); + this.logger.debug({ id: payload.transaction?.id }, "Mapping the state of transaction"); const dataParseResult = transactionProcessPayloadDataSchema.safeParse(payload.data); if (!dataParseResult.success) { @@ -89,16 +99,16 @@ export class TransactionProcessSessionService { }); } - const { transactionId } = dataParseResult.data; + const { transactionId: authorizeTransactionId } = dataParseResult.data; - await this.saveTransactionIdInMetadata({ + await this.synchronizeTransaction({ saleorTransactionId: payload.transaction.id, - authorizeTransactionId: transactionId, + authorizeTransactionId, }); const transactionDetailsClient = new TransactionDetailsClient(this.authorizeConfig); const details = await transactionDetailsClient.getTransactionDetailsRequest({ - transactionId, + transactionId: authorizeTransactionId, }); const { result, actions } = this.mapTransactionToWebhookResponse(details); @@ -108,7 +118,7 @@ export class TransactionProcessSessionService { result, actions, message: details.transaction.responseReasonDescription, - pspReference: transactionId, + pspReference: authorizeTransactionId, }; } } diff --git a/src/modules/webhooks/transaction-refund-requested.ts b/src/modules/webhooks/transaction-refund-requested.ts index 995d6d0..c602d82 100644 --- a/src/modules/webhooks/transaction-refund-requested.ts +++ b/src/modules/webhooks/transaction-refund-requested.ts @@ -2,12 +2,14 @@ import AuthorizeNet from "authorizenet"; import { type Client } from "urql"; import { type AuthorizeProviderConfig } from "../authorize-net/authorize-net-config"; import { CreateTransactionClient } from "../authorize-net/client/create-transaction"; -import { TransactionMetadataManager } from "../configuration/transaction-metadata-manager"; -import { type MetadataItem, type TransactionRefundRequestedEventFragment } from "generated/graphql"; +import { SynchronizedTransactionIdResolver } from "../authorize-net/synchronized-transaction/synchronized-transaction-id-resolver"; +import { createSynchronizedTransactionRequest } from "../authorize-net/synchronized-transaction/create-synchronized-transaction-request"; +import { type TransactionRefundRequestedEventFragment } from "generated/graphql"; import { BaseError } from "@/errors"; import { createLogger } from "@/lib/logger"; import { type TransactionRefundRequestedResponse } from "@/schemas/TransactionRefundRequested/TransactionRefundRequestedResponse.mjs"; +import { invariant } from "@/lib/invariant"; const ApiContracts = AuthorizeNet.APIContracts; @@ -34,21 +36,18 @@ export class TransactionRefundRequestedService { this.apiClient = apiClient; } - private async getTransactionIdFromMetadata({ metadata }: { metadata: readonly MetadataItem[] }) { - const metadataManager = new TransactionMetadataManager({ apiClient: this.apiClient }); - const transactionId = await metadataManager.getAuthorizeTransactionId({ metadata }); - - return transactionId; - } - private async buildTransactionFromPayload({ authorizeTransactionId, + saleorTransactionId, }: { authorizeTransactionId: string; + saleorTransactionId: string; }): Promise { - const transactionRequest = new ApiContracts.TransactionRequestType(); + const transactionRequest = createSynchronizedTransactionRequest({ + saleorTransactionId, + authorizeTransactionId, + }); transactionRequest.setTransactionType(ApiContracts.TransactionTypeEnum.VOIDTRANSACTION); - transactionRequest.setRefTransId(authorizeTransactionId); return transactionRequest; } @@ -56,20 +55,20 @@ export class TransactionRefundRequestedService { async execute( payload: TransactionRefundRequestedEventFragment, ): Promise { - this.logger.debug({ id: payload.transaction?.id }, "Called execute with"); + this.logger.debug({ id: payload.transaction?.id }, "Refunding the transaction"); - const saleorTransactionId = payload.transaction?.id; + invariant(payload.transaction, "Transaction is missing"); - if (!saleorTransactionId) { - throw new TransactionRefundRequestedError("Missing saleorTransactionId in payload"); - } + const idResolver = new SynchronizedTransactionIdResolver(this.apiClient); + const { saleorTransactionId, authorizeTransactionId } = await idResolver.resolveFromTransaction( + payload.transaction, + ); - const authorizeTransactionId = await this.getTransactionIdFromMetadata({ - metadata: payload.transaction.privateMetadata ?? [], + const transactionInput = await this.buildTransactionFromPayload({ + authorizeTransactionId, + saleorTransactionId, }); - const transactionInput = await this.buildTransactionFromPayload({ authorizeTransactionId }); - const createTransactionClient = new CreateTransactionClient(this.authorizeConfig); await createTransactionClient.createTransaction(transactionInput); diff --git a/src/modules/webhooks/webhook-manager-service.ts b/src/modules/webhooks/webhook-manager-service.ts index 17fe7be..29236dc 100644 --- a/src/modules/webhooks/webhook-manager-service.ts +++ b/src/modules/webhooks/webhook-manager-service.ts @@ -2,27 +2,22 @@ import { type Client } from "urql"; import { type AuthData } from "@saleor/app-sdk/APL"; import { type AuthorizeProviderConfig } from "../authorize-net/authorize-net-config"; -import { ActiveProviderResolver } from "../configuration/active-provider-resolver"; -import { AppConfigMetadataManager } from "../configuration/app-config-metadata-manager"; -import { AppConfigResolver } from "../configuration/app-config-resolver"; +import { TransactionCancelationRequestedService } from "./transaction-cancelation-requested"; import { TransactionInitializeSessionService } from "./transaction-initialize-session"; import { TransactionProcessSessionService } from "./transaction-process-session"; -import { TransactionCancelationRequestedService } from "./transaction-cancelation-requested"; import { TransactionRefundRequestedService } from "./transaction-refund-requested"; import { type TransactionInitializeSessionResponse } from "@/schemas/TransactionInitializeSession/TransactionInitializeSessionResponse.mjs"; +import { type TransactionCancelationRequestedResponse } from "@/schemas/TransactionCancelationRequested/TransactionCancelationRequestedResponse.mjs"; +import { type TransactionProcessSessionResponse } from "@/schemas/TransactionProcessSession/TransactionProcessSessionResponse.mjs"; +import { type TransactionRefundRequestedResponse } from "@/schemas/TransactionRefundRequested/TransactionRefundRequestedResponse.mjs"; import { - type TransactionRefundRequestedEventFragment, - type MetadataItem, type TransactionCancelationRequestedEventFragment, type TransactionInitializeSessionEventFragment, type TransactionProcessSessionEventFragment, + type TransactionRefundRequestedEventFragment, } from "generated/graphql"; -import { type TransactionProcessSessionResponse } from "@/schemas/TransactionProcessSession/TransactionProcessSessionResponse.mjs"; -import { type TransactionCancelationRequestedResponse } from "@/schemas/TransactionCancelationRequested/TransactionCancelationRequestedResponse.mjs"; -import { logger } from "@/lib/logger"; import { createServerClient } from "@/lib/create-graphq-client"; -import { type TransactionRefundRequestedResponse } from "@/schemas/TransactionRefundRequested/TransactionRefundRequestedResponse.mjs"; export interface PaymentsWebhooks { transactionInitializeSession: ( @@ -36,7 +31,7 @@ export interface PaymentsWebhooks { ) => Promise; } -export class WebhookManagerService implements PaymentsWebhooks { +export class AppWebhookManager implements PaymentsWebhooks { private authorizeConfig: AuthorizeProviderConfig.FullShape; private apiClient: Client; @@ -95,35 +90,18 @@ export class WebhookManagerService implements PaymentsWebhooks { } } -/** - * 1. Resolve appConfig from either webhook app metadata or environment variables. - * 2. Resolve active provider config from appConfig and channel slug. - * 3. Return webhook manager service created with the active provider config. - */ -export async function getWebhookManagerServiceFromCtx({ - appMetadata, - channelSlug, +export async function createAppWebhookManager({ authData, + authorizeConfig, }: { - appMetadata: readonly MetadataItem[]; - channelSlug: string; authData: AuthData; + authorizeConfig: AuthorizeProviderConfig.FullShape; }) { - const appConfigMetadataManager = AppConfigMetadataManager.createFromAuthData(authData); - const appConfigResolver = new AppConfigResolver(appConfigMetadataManager); - - const appConfig = await appConfigResolver.resolve({ metadata: appMetadata }); - const activeProviderResolver = new ActiveProviderResolver(appConfig); - const authorizeConfig = activeProviderResolver.resolve(channelSlug); - const apiClient = createServerClient(authData.saleorApiUrl, authData.token); - - logger.trace(`Found authorizeConfig for channel ${channelSlug}`); - - const webhookManagerService = new WebhookManagerService({ + const appWebhookManager = new AppWebhookManager({ authorizeConfig, apiClient, }); - return webhookManagerService; + return appWebhookManager; } diff --git a/src/pages/api/webhooks/authorize.ts b/src/pages/api/webhooks/authorize.ts new file mode 100644 index 0000000..88be6d0 --- /dev/null +++ b/src/pages/api/webhooks/authorize.ts @@ -0,0 +1,30 @@ +import { type NextApiRequest, type NextApiResponse } from "next"; +import { createLogger } from "@/lib/logger"; + +import { AuthorizeNetWebhookHandler } from "@/modules/authorize-net/webhook/authorize-net-webhook-handler"; + +const logger = createLogger({ + name: "AuthorizeNetWebhooksHandler", +}); + +export const config = { + api: { + bodyParser: false /** Disables automatic body parsing, so we can use raw-body. + @see: authorize-net-webhook-handler.ts + */, + }, +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + logger.debug({ url: req.url }, "Received webhook request"); + const handler = new AuthorizeNetWebhookHandler(req); + + await handler.handle(); + res.status(200).end(); + } catch (error) { + // eslint-disable-next-line @saleor/saleor-app/logger-leak + logger.error({ error }, "Error in webhook handler"); + res.status(500).json({ error }); + } +} diff --git a/src/pages/api/webhooks/transaction-cancelation-requested.ts b/src/pages/api/webhooks/transaction-cancelation-requested.ts index 7404b09..26bd09c 100644 --- a/src/pages/api/webhooks/transaction-cancelation-requested.ts +++ b/src/pages/api/webhooks/transaction-cancelation-requested.ts @@ -3,13 +3,17 @@ import * as Sentry from "@sentry/nextjs"; import { createLogger } from "@/lib/logger"; import { SynchronousWebhookResponseBuilder } from "@/lib/webhook-response-builder"; import { TransactionCancelationRequestedError } from "@/modules/webhooks/transaction-cancelation-requested"; -import { getWebhookManagerServiceFromCtx } from "@/modules/webhooks/webhook-manager-service"; + +import { resolveAppConfigFromCtx } from "@/modules/configuration/app-config-resolver"; +import { resolveAuthorizeConfigFromAppConfig } from "@/modules/configuration/authorize-config-resolver"; +import { createAppWebhookManager } from "@/modules/webhooks/webhook-manager-service"; import { saleorApp } from "@/saleor-app"; import { type TransactionCancelationRequestedResponse } from "@/schemas/TransactionCancelationRequested/TransactionCancelationRequestedResponse.mjs"; import { UntypedTransactionCancelationRequestedDocument, type TransactionCancelationRequestedEventFragment, } from "generated/graphql"; +import { AuthorizeWebhookManager } from "@/modules/authorize-net/webhook/authorize-net-webhook-manager"; export const config = { api: { @@ -35,29 +39,49 @@ class WebhookResponseBuilder extends SynchronousWebhookResponseBuilder { - const responseBuilder = new WebhookResponseBuilder(res); - logger.debug({ payload: ctx.payload }, "handler called"); - - try { - const webhookManagerService = await getWebhookManagerServiceFromCtx({ - appMetadata: ctx.payload.recipient?.privateMetadata ?? [], - channelSlug: ctx.payload.transaction?.sourceObject?.channel.slug ?? "", - authData: ctx.authData, - }); - - const response = await webhookManagerService.transactionCancelationRequested(ctx.payload); - // eslint-disable-next-line @saleor/saleor-app/logger-leak - logger.info({ response }, "Responding with:"); - return responseBuilder.ok(response); - } catch (error) { - Sentry.captureException(error); - - const normalizedError = TransactionCancelationRequestedError.normalize(error); - return responseBuilder.ok({ - result: "CANCEL_FAILURE", - pspReference: "", // todo: add - message: normalizedError.message, - }); - } -}); +export default transactionCancelationRequestedSyncWebhook.createHandler( + async (req, res, { authData, ...ctx }) => { + const responseBuilder = new WebhookResponseBuilder(res); + logger.debug({ payload: ctx.payload }, "handler called"); + + try { + const channelSlug = ctx.payload.transaction?.sourceObject?.channel.slug ?? ""; + const appConfig = await resolveAppConfigFromCtx({ + authData, + appMetadata: ctx.payload.recipient?.privateMetadata ?? [], + }); + + const authorizeConfig = resolveAuthorizeConfigFromAppConfig({ + appConfig, + channelSlug, + }); + + const authorizeWebhookManager = new AuthorizeWebhookManager({ + authData, + appConfig, + channelSlug, + }); + + await authorizeWebhookManager.register(); + + const appWebhookManager = await createAppWebhookManager({ + authData, + authorizeConfig, + }); + + const response = await appWebhookManager.transactionCancelationRequested(ctx.payload); + // eslint-disable-next-line @saleor/saleor-app/logger-leak + logger.info({ response }, "Responding with:"); + return responseBuilder.ok(response); + } catch (error) { + Sentry.captureException(error); + + const normalizedError = TransactionCancelationRequestedError.normalize(error); + return responseBuilder.ok({ + result: "CANCEL_FAILURE", + pspReference: "", // todo: add + message: normalizedError.message, + }); + } + }, +); diff --git a/src/pages/api/webhooks/transaction-initialize-session.ts b/src/pages/api/webhooks/transaction-initialize-session.ts index c52b4a5..f9b1f4a 100644 --- a/src/pages/api/webhooks/transaction-initialize-session.ts +++ b/src/pages/api/webhooks/transaction-initialize-session.ts @@ -1,9 +1,12 @@ import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next"; import * as Sentry from "@sentry/nextjs"; +import { normalizeError } from "@/errors"; import { createLogger } from "@/lib/logger"; import { SynchronousWebhookResponseBuilder } from "@/lib/webhook-response-builder"; -import { TransactionInitializeError } from "@/modules/webhooks/transaction-initialize-session"; -import { getWebhookManagerServiceFromCtx } from "@/modules/webhooks/webhook-manager-service"; +import { AuthorizeWebhookManager } from "@/modules/authorize-net/webhook/authorize-net-webhook-manager"; +import { resolveAppConfigFromCtx } from "@/modules/configuration/app-config-resolver"; +import { resolveAuthorizeConfigFromAppConfig } from "@/modules/configuration/authorize-config-resolver"; +import { createAppWebhookManager } from "@/modules/webhooks/webhook-manager-service"; import { saleorApp } from "@/saleor-app"; import { type TransactionInitializeSessionResponse } from "@/schemas/TransactionInitializeSession/TransactionInitializeSessionResponse.mjs"; import { @@ -39,35 +42,55 @@ class WebhookResponseBuilder extends SynchronousWebhookResponseBuilder { - const responseBuilder = new WebhookResponseBuilder(res); - logger.info({ action: ctx.payload.action }, "called with:"); +export default transactionInitializeSessionSyncWebhook.createHandler( + async (req, res, { authData, ...ctx }) => { + const responseBuilder = new WebhookResponseBuilder(res); + logger.info({ action: ctx.payload.action }, "called with:"); - try { - const webhookManagerService = await getWebhookManagerServiceFromCtx({ - appMetadata: ctx.payload.recipient?.privateMetadata ?? [], - channelSlug: ctx.payload.sourceObject.channel.slug, - authData: ctx.authData, - }); + try { + const channelSlug = ctx.payload.sourceObject.channel.slug; + const appConfig = await resolveAppConfigFromCtx({ + authData, + appMetadata: ctx.payload.recipient?.privateMetadata ?? [], + }); - const response = await webhookManagerService.transactionInitializeSession(ctx.payload); + const authorizeConfig = resolveAuthorizeConfigFromAppConfig({ + appConfig, + channelSlug, + }); - // eslint-disable-next-line @saleor/saleor-app/logger-leak - logger.info({ response }, "Responding with:"); - return responseBuilder.ok(response); - } catch (error) { - Sentry.captureException(error); + const authorizeWebhookManager = new AuthorizeWebhookManager({ + authData, + appConfig, + channelSlug, + }); - const normalizedError = TransactionInitializeError.normalize(error); - return responseBuilder.ok({ - amount: 0, // 0 or real amount? - result: "AUTHORIZATION_FAILURE", - message: "Failure", - data: { - error: { - message: normalizedError.message, + await authorizeWebhookManager.register(); + + const appWebhookManager = await createAppWebhookManager({ + authData, + authorizeConfig, + }); + + const response = await appWebhookManager.transactionInitializeSession(ctx.payload); + + // eslint-disable-next-line @saleor/saleor-app/logger-leak + logger.info({ response }, "Responding with:"); + return responseBuilder.ok(response); + } catch (error) { + Sentry.captureException(error); + + const normalizedError = normalizeError(error); + return responseBuilder.ok({ + amount: ctx.payload.action.amount, + result: "AUTHORIZATION_FAILURE", + message: "Failure", + data: { + error: { + message: normalizedError.message, + }, }, - }, - }); - } -}); + }); + } + }, +); diff --git a/src/pages/api/webhooks/transaction-process-session.ts b/src/pages/api/webhooks/transaction-process-session.ts index d2907eb..7052164 100644 --- a/src/pages/api/webhooks/transaction-process-session.ts +++ b/src/pages/api/webhooks/transaction-process-session.ts @@ -1,9 +1,12 @@ import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next"; import * as Sentry from "@sentry/nextjs"; +import { AuthorizeWebhookManager } from "@/modules/authorize-net/webhook/authorize-net-webhook-manager"; import { createLogger } from "@/lib/logger"; import { SynchronousWebhookResponseBuilder } from "@/lib/webhook-response-builder"; +import { resolveAppConfigFromCtx } from "@/modules/configuration/app-config-resolver"; +import { resolveAuthorizeConfigFromAppConfig } from "@/modules/configuration/authorize-config-resolver"; import { TransactionProcessError } from "@/modules/webhooks/transaction-process-session"; -import { getWebhookManagerServiceFromCtx } from "@/modules/webhooks/webhook-manager-service"; +import { createAppWebhookManager } from "@/modules/webhooks/webhook-manager-service"; import { saleorApp } from "@/saleor-app"; import { type TransactionProcessSessionResponse } from "@/schemas/TransactionProcessSession/TransactionProcessSessionResponse.mjs"; import { @@ -39,43 +42,64 @@ class WebhookResponseBuilder extends SynchronousWebhookResponseBuilder { - const responseBuilder = new WebhookResponseBuilder(res); - // todo: add more extensive logs - logger.debug( - { - action: ctx.payload.action, - channelSlug: ctx.payload.sourceObject.channel.slug, - transaction: ctx.payload.transaction, - }, - "Handler called", - ); +export default transactionProcessSessionSyncWebhook.createHandler( + async (req, res, { authData, ...ctx }) => { + const responseBuilder = new WebhookResponseBuilder(res); + const channelSlug = ctx.payload.sourceObject.channel.slug; - try { - const webhookManagerService = await getWebhookManagerServiceFromCtx({ - appMetadata: ctx.payload.recipient?.privateMetadata ?? [], - channelSlug: ctx.payload.sourceObject.channel.slug, - authData: ctx.authData, - }); + // todo: add more extensive logs + logger.debug( + { + action: ctx.payload.action, + channelSlug, + transaction: ctx.payload.transaction, + }, + "Handler called", + ); + + try { + const appConfig = await resolveAppConfigFromCtx({ + authData, + appMetadata: ctx.payload.recipient?.privateMetadata ?? [], + }); + + const authorizeConfig = resolveAuthorizeConfigFromAppConfig({ + appConfig, + channelSlug, + }); + + const authorizeWebhookManager = new AuthorizeWebhookManager({ + authData, + appConfig, + channelSlug, + }); + + await authorizeWebhookManager.register(); - const response = await webhookManagerService.transactionProcessSession(ctx.payload); + const appWebhookManager = await createAppWebhookManager({ + authData, + authorizeConfig, + }); - // eslint-disable-next-line @saleor/saleor-app/logger-leak - logger.info({ response }, "Responding with:"); - return responseBuilder.ok(response); - } catch (error) { - Sentry.captureException(error); + const response = await appWebhookManager.transactionProcessSession(ctx.payload); - const normalizedError = TransactionProcessError.normalize(error); - return responseBuilder.ok({ - amount: ctx.payload.action.amount, - result: "AUTHORIZATION_FAILURE", - message: "Failure", - data: { - error: { - message: normalizedError.message, + // eslint-disable-next-line @saleor/saleor-app/logger-leak + logger.info({ response }, "Responding with:"); + return responseBuilder.ok(response); + } catch (error) { + Sentry.captureException(error); + + const normalizedError = TransactionProcessError.normalize(error); + return responseBuilder.ok({ + amount: ctx.payload.action.amount, + result: "AUTHORIZATION_FAILURE", + message: "Failure", + data: { + error: { + message: normalizedError.message, + }, }, - }, - }); - } -}); + }); + } + }, +); diff --git a/src/pages/api/webhooks/transaction-refund-requested.ts b/src/pages/api/webhooks/transaction-refund-requested.ts index 59f117f..1f284bf 100644 --- a/src/pages/api/webhooks/transaction-refund-requested.ts +++ b/src/pages/api/webhooks/transaction-refund-requested.ts @@ -3,7 +3,11 @@ import * as Sentry from "@sentry/nextjs"; import { createLogger } from "@/lib/logger"; import { SynchronousWebhookResponseBuilder } from "@/lib/webhook-response-builder"; import { TransactionRefundRequestedError } from "@/modules/webhooks/transaction-refund-requested"; -import { getWebhookManagerServiceFromCtx } from "@/modules/webhooks/webhook-manager-service"; + +import { AuthorizeWebhookManager } from "@/modules/authorize-net/webhook/authorize-net-webhook-manager"; +import { resolveAppConfigFromCtx } from "@/modules/configuration/app-config-resolver"; +import { resolveAuthorizeConfigFromAppConfig } from "@/modules/configuration/authorize-config-resolver"; +import { createAppWebhookManager } from "@/modules/webhooks/webhook-manager-service"; import { saleorApp } from "@/saleor-app"; import { type TransactionRefundRequestedResponse } from "@/schemas/TransactionRefundRequested/TransactionRefundRequestedResponse.mjs"; import { @@ -35,29 +39,49 @@ class WebhookResponseBuilder extends SynchronousWebhookResponseBuilder { - const responseBuilder = new WebhookResponseBuilder(res); - logger.debug({ payload: ctx.payload }, "handler called"); - - try { - const webhookManagerService = await getWebhookManagerServiceFromCtx({ - appMetadata: ctx.payload.recipient?.privateMetadata ?? [], - channelSlug: ctx.payload.transaction?.sourceObject?.channel.slug ?? "", - authData: ctx.authData, - }); - - const response = await webhookManagerService.transactionRefundRequested(ctx.payload); - // eslint-disable-next-line @saleor/saleor-app/logger-leak - logger.info({ response }, "Responding with:"); - return responseBuilder.ok(response); - } catch (error) { - Sentry.captureException(error); - - const normalizedError = TransactionRefundRequestedError.normalize(error); - return responseBuilder.ok({ - result: "REFUND_FAILURE", - pspReference: "", // todo: add - message: normalizedError.message, - }); - } -}); +export default transactionRefundRequestedSyncWebhook.createHandler( + async (req, res, { authData, ...ctx }) => { + const responseBuilder = new WebhookResponseBuilder(res); + logger.debug({ payload: ctx.payload }, "handler called"); + + try { + const channelSlug = ctx.payload.transaction?.sourceObject?.channel.slug ?? ""; + const appConfig = await resolveAppConfigFromCtx({ + authData, + appMetadata: ctx.payload.recipient?.privateMetadata ?? [], + }); + + const authorizeConfig = resolveAuthorizeConfigFromAppConfig({ + channelSlug, + appConfig, + }); + + const authorizeWebhookManager = new AuthorizeWebhookManager({ + authData, + appConfig, + channelSlug, + }); + + await authorizeWebhookManager.register(); + + const appWebhookManager = await createAppWebhookManager({ + authData, + authorizeConfig, + }); + + const response = await appWebhookManager.transactionRefundRequested(ctx.payload); + // eslint-disable-next-line @saleor/saleor-app/logger-leak + logger.info({ response }, "Responding with:"); + return responseBuilder.ok(response); + } catch (error) { + Sentry.captureException(error); + + const normalizedError = TransactionRefundRequestedError.normalize(error); + return responseBuilder.ok({ + result: "REFUND_FAILURE", + pspReference: "", // todo: add + message: normalizedError.message, + }); + } + }, +);