diff --git a/.changeset/blue-lies-enjoy.md b/.changeset/blue-lies-enjoy.md new file mode 100644 index 0000000..adffbc9 --- /dev/null +++ b/.changeset/blue-lies-enjoy.md @@ -0,0 +1,5 @@ +--- +"saleor-app-taxes": major +--- + +Redesigned the app's UI with the new version of macaw-ui. Introduced breaking changes in the structure of providers configuration and channels configuration. Added migrations that convert the obsolete configurations to the new format. Added address validation for tax providers. diff --git a/.changeset/eight-toes-kneel.md b/.changeset/eight-toes-kneel.md new file mode 100644 index 0000000..c5f245e --- /dev/null +++ b/.changeset/eight-toes-kneel.md @@ -0,0 +1,5 @@ +--- +"@saleor/react-hook-form-macaw": minor +--- + +Added a binding for the macaw-ui's Toggle component. diff --git a/.changeset/few-boxes-doubt.md b/.changeset/few-boxes-doubt.md new file mode 100644 index 0000000..752465c --- /dev/null +++ b/.changeset/few-boxes-doubt.md @@ -0,0 +1,5 @@ +--- +"@saleor/apps-ui": patch +--- + +Fixed a missing text-decoration on a breadcrumb link. diff --git a/apps/taxes/next.config.js b/apps/taxes/next.config.js index 2fa335c..d8df423 100644 --- a/apps/taxes/next.config.js +++ b/apps/taxes/next.config.js @@ -1,31 +1,52 @@ -/* - * This file sets a custom webpack configuration to use your Next.js app - * with Sentry. - * https://nextjs.org/docs/api-reference/next.config.js/introduction - * https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ - */ -const { withSentryConfig } = require("@sentry/nextjs"); - -const isSentryPropertiesInEnvironment = - process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG; - -/** - * @type {import('next').NextConfig} - */ +/** @type {import('next').NextConfig} */ const config = { reactStrictMode: true, - experimental: { - esmExternals: true, - }, - transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui"], + transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"], }; +const isSentryEnvAvailable = + process.env.SENTRY_AUTH_TOKEN && + process.env.SENTRY_PROJECT && + process.env.SENTRY_ORG && + process.env.SENTRY_AUTH_TOKEN; + +const { withSentryConfig } = require("@sentry/nextjs"); + module.exports = withSentryConfig( config, - { silent: true }, { - hideSourcemaps: true, - disableServerWebpackPlugin: !isSentryPropertiesInEnvironment, - disableClientWebpackPlugin: !isSentryPropertiesInEnvironment, + /* + * For all available options, see: + * https://github.com/getsentry/sentry-webpack-plugin#options + */ + + // Suppresses source map uploading logs during build + silent: true, + + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + }, + { + disableClientWebpackPlugin: !isSentryEnvAvailable, + disableServerWebpackPlugin: !isSentryEnvAvailable, + /* + * For all available options, see: + * https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + */ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Transpiles SDK to be compatible with IE11 (increases bundle size) + transpileClientSDK: true, + + // Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load) + tunnelRoute: "/monitoring", + + // Hides source maps from generated client bundles + hideSourceMaps: true, + + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: true, } ); diff --git a/apps/taxes/package.json b/apps/taxes/package.json index 65b2875..74bc51a 100644 --- a/apps/taxes/package.json +++ b/apps/taxes/package.json @@ -13,13 +13,11 @@ }, "dependencies": { "@hookform/resolvers": "^2.9.10", - "@material-ui/core": "^4.12.4", - "@material-ui/icons": "^4.11.3", - "@material-ui/lab": "4.0.0-alpha.61", "@saleor/app-sdk": "0.40.1", "@saleor/apps-shared": "workspace:*", "@saleor/apps-ui": "workspace:*", - "@saleor/macaw-ui": "^0.7.2", + "@saleor/macaw-ui": "^0.8.0-pre.84", + "@saleor/react-hook-form-macaw": "workspace:*", "@sentry/nextjs": "^7.45.0", "@tanstack/react-query": "^4.19.1", "@trpc/client": "^10.9.0", @@ -30,7 +28,8 @@ "@urql/exchange-multipart-fetch": "^1.0.1", "avatax": "^23.3.2", "clsx": "^1.2.1", - "graphql": "16.6.0", + "dotenv": "^16.0.3", + "graphql": "^16.6.0", "graphql-tag": "^2.12.6", "jotai": "^2.0.0", "jsdom": "^20.0.3", @@ -39,7 +38,7 @@ "pino-pretty": "^10.0.0", "react": "18.2.0", "react-dom": "18.2.0", - "react-hook-form": "^7.42.1", + "react-hook-form": "^7.43.9", "taxjar": "^4.0.1", "urql": "^3.0.3", "usehooks-ts": "^2.9.1", diff --git a/apps/taxes/scripts/migrations/channels-config-schema-v1.ts b/apps/taxes/scripts/migrations/channels-config-schema-v1.ts new file mode 100644 index 0000000..42fc1f6 --- /dev/null +++ b/apps/taxes/scripts/migrations/channels-config-schema-v1.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +const addressSchema = z.object({ + country: z.string(), + zip: z.string(), + state: z.string(), + city: z.string(), + street: z.string(), +}); + +const channelSchema = z.object({ + providerInstanceId: z.string(), + enabled: z.boolean(), + address: addressSchema, +}); + +export type ChannelV1 = z.infer; + +const channelsV1Schema = z.record(channelSchema); + +export type ChannelsV1 = z.infer; diff --git a/apps/taxes/scripts/migrations/channels-config-schema-v2.ts b/apps/taxes/scripts/migrations/channels-config-schema-v2.ts new file mode 100644 index 0000000..c3a5f21 --- /dev/null +++ b/apps/taxes/scripts/migrations/channels-config-schema-v2.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; +import { channelsSchema } from "../../src/modules/channel-configuration/channel-config"; + +export const channelsV2Schema = channelsSchema; + +export type ChannelsV2 = z.infer; diff --git a/apps/taxes/scripts/migrations/migration-utils.ts b/apps/taxes/scripts/migrations/migration-utils.ts new file mode 100644 index 0000000..45c98a9 --- /dev/null +++ b/apps/taxes/scripts/migrations/migration-utils.ts @@ -0,0 +1,30 @@ +/* eslint-disable turbo/no-undeclared-env-vars */ + +import { SaleorCloudAPL } from "@saleor/app-sdk/APL"; +import { createClient } from "../../src/lib/graphql"; +import { createSettingsManager } from "../../src/modules/app/metadata-manager"; + +export const getMetadataManagerForEnv = (apiUrl: string, appToken: string, appId: string) => { + const client = createClient(apiUrl, async () => ({ + token: appToken, + })); + + return createSettingsManager(client, appId); +}; + +export const verifyRequiredEnvs = () => { + const requiredEnvs = ["SALEOR_CLOUD_TOKEN", "SALEOR_CLOUD_RESOURCE_URL", "SECRET_KEY"]; + + if (!requiredEnvs.every((env) => process.env[env])) { + throw new Error(`Missing envs: ${requiredEnvs.join(" | ")}`); + } +}; + +export const fetchCloudAplEnvs = () => { + const saleorAPL = new SaleorCloudAPL({ + token: process.env.SALEOR_CLOUD_TOKEN!, + resourceUrl: process.env.SALEOR_CLOUD_RESOURCE_URL!, + }); + + return saleorAPL.getAll(); +}; diff --git a/apps/taxes/scripts/migrations/run-generate-dummy-data.ts b/apps/taxes/scripts/migrations/run-generate-dummy-data.ts new file mode 100644 index 0000000..5934ea9 --- /dev/null +++ b/apps/taxes/scripts/migrations/run-generate-dummy-data.ts @@ -0,0 +1,122 @@ +import { saleorApp } from "../../saleor-app"; +import { createClient } from "../../src/lib/graphql"; +import { Logger, createLogger } from "../../src/lib/logger"; +import { createSettingsManager } from "../../src/modules/app/metadata-manager"; +import { TaxProvidersV1 } from "./tax-providers-config-schema-v1"; +import { TaxProvidersPrivateMetadataManagerV1 } from "./tax-providers-metadata-manager-v1"; +import { ChannelsV1 } from "./channels-config-schema-v1"; + +import * as dotenv from "dotenv"; +import { TaxChannelsPrivateMetadataManagerV1 } from "./tax-channels-metadata-manager-v1"; + +dotenv.config(); + +export const dummyChannelsV1Config: ChannelsV1 = { + "default-channel": { + providerInstanceId: "24822834-1a49-4b51-8a59-579affdb772f", + address: { + city: "San Francisco", + country: "US", + state: "CA", + street: "Sesame Street", + zip: "10001", + }, + enabled: true, + }, + "channel-pln": { + providerInstanceId: "d15d9907-a3cb-42d2-9336-366d2366e91b", + address: { + city: "San Francisco", + country: "US", + state: "CA", + street: "Sesame Street", + zip: "10001", + }, + enabled: true, + }, +}; + +export const dummyTaxProvidersV1Config: TaxProvidersV1 = [ + { + provider: "avatax", + id: "24822834-1a49-4b51-8a59-579affdb772f", + config: { + isAutocommit: true, + isSandbox: true, + name: "Avatalara1", + password: "password", + username: "username", + companyCode: "companyCode", + shippingTaxCode: "shippingTaxCode", + }, + }, + { + provider: "taxjar", + id: "d15d9907-a3cb-42d2-9336-366d2366e91b", + config: { + isSandbox: true, + apiKey: "apiKey", + name: "TaxJar1", + }, + }, +]; + +// This class is used to generate dummy config for the app to check if the runtime migrations work as expected. +class DummyConfigGenerator { + private logger: Logger; + constructor(private domain: string) { + this.logger = createLogger({ + name: "DummyConfigGenerator", + }); + } + private getFileApl = () => { + return saleorApp.apl.getAll(); + }; + + private generateDummyTaxProvidersConfig = (): TaxProvidersV1 => dummyTaxProvidersV1Config; + + private generateDummyTaxChannelsConfig = (): ChannelsV1 => dummyChannelsV1Config; + + generate = async () => { + console.log("Generating dummy config"); + const apls = await this.getFileApl(); + + console.log({ apls }, "Apls retrieved"); + + const target = apls.find((apl) => apl.domain === this.domain); + + if (!target) { + throw new Error(`Domain ${this.domain} not found in apls`); + } + + const dummyTaxProvidersConfig = this.generateDummyTaxProvidersConfig(); + const dummyTaxChannelsConfig = this.generateDummyTaxChannelsConfig(); + + console.log({ dummyTaxProvidersConfig, dummyTaxChannelsConfig }, "Dummy configs generated"); + + const client = createClient(target.saleorApiUrl, async () => + Promise.resolve({ token: target.token }) + ); + const metadataManager = createSettingsManager(client, target.appId); + const taxProvidersManager = new TaxProvidersPrivateMetadataManagerV1( + metadataManager, + target.saleorApiUrl + ); + + const taxChannelsManager = new TaxChannelsPrivateMetadataManagerV1( + metadataManager, + target.saleorApiUrl + ); + + console.log("Setting dummy configs"); + + await taxProvidersManager.setConfig(dummyTaxProvidersConfig); + await taxChannelsManager.setConfig(dummyTaxChannelsConfig); + + console.log("Dummy config set"); + }; +} + +// const dummyConfigGenerator = new DummyConfigGenerator(""); + +// dummyConfigGenerator.generate(); diff --git a/apps/taxes/scripts/migrations/run-migration.ts b/apps/taxes/scripts/migrations/run-migration.ts new file mode 100644 index 0000000..3d0e4db --- /dev/null +++ b/apps/taxes/scripts/migrations/run-migration.ts @@ -0,0 +1,78 @@ +/* eslint-disable turbo/no-undeclared-env-vars */ + +import * as dotenv from "dotenv"; +import { fetchCloudAplEnvs, getMetadataManagerForEnv, verifyRequiredEnvs } from "./migration-utils"; +import { TaxProvidersV1toV2MigrationManager } from "./tax-providers-migration-v1-to-v2"; +import { TaxChannelsV1toV2MigrationManager } from "./tax-channels-migration-v1-to-v2"; + +dotenv.config(); + +const runMigration = async () => { + console.log("Starting running migration"); + + verifyRequiredEnvs(); + + console.log("Envs verified, fetching envs"); + const allEnvs = await fetchCloudAplEnvs().catch((r) => { + console.error(r); + + process.exit(1); + }); + + const report = { + taxProviders: [] as string[], + taxChannels: [] as string[], + none: [] as string[], + }; + + for (const env of allEnvs) { + let isTaxProvidersMigrated = false; + let isTaxChannelsMigrated = false; + + console.log("Working on env: ", env.saleorApiUrl); + + const metadataManager = getMetadataManagerForEnv(env.saleorApiUrl, env.token, env.appId); + + const taxProvidersMigrationManager = new TaxProvidersV1toV2MigrationManager( + metadataManager, + env.saleorApiUrl, + { mode: "migrate" } + ); + + const taxProvidersMigratedConfig = await taxProvidersMigrationManager.migrateIfNeeded(); + + if (taxProvidersMigratedConfig) { + console.log("Config migrated", taxProvidersMigratedConfig); + isTaxProvidersMigrated = true; + } + + const taxChannelsMigrationManager = new TaxChannelsV1toV2MigrationManager( + metadataManager, + env.saleorApiUrl, + { mode: "migrate" } + ); + + const taxChannelsMigratedConfig = await taxChannelsMigrationManager.migrateIfNeeded(); + + if (taxChannelsMigratedConfig) { + console.log("Config migrated", taxChannelsMigratedConfig); + isTaxChannelsMigrated = true; + } + + if (isTaxProvidersMigrated) { + report.taxProviders.push(env.saleorApiUrl); + } + + if (isTaxChannelsMigrated) { + report.taxChannels.push(env.saleorApiUrl); + } + + if (!isTaxProvidersMigrated && !isTaxChannelsMigrated) { + report.none.push(env.saleorApiUrl); + } + } + + console.log("Report", report); +}; + +runMigration(); diff --git a/apps/taxes/scripts/migrations/run-report.ts b/apps/taxes/scripts/migrations/run-report.ts new file mode 100644 index 0000000..835eadf --- /dev/null +++ b/apps/taxes/scripts/migrations/run-report.ts @@ -0,0 +1,78 @@ +/* eslint-disable turbo/no-undeclared-env-vars */ + +import * as dotenv from "dotenv"; +import { fetchCloudAplEnvs, getMetadataManagerForEnv, verifyRequiredEnvs } from "./migration-utils"; +import { TaxProvidersV1toV2MigrationManager } from "./tax-providers-migration-v1-to-v2"; +import { TaxChannelsV1toV2MigrationManager } from "./tax-channels-migration-v1-to-v2"; + +dotenv.config(); + +const runReport = async () => { + console.log("Starting running migration"); + + verifyRequiredEnvs(); + + console.log("Envs verified, fetching envs"); + const allEnvs = await fetchCloudAplEnvs().catch((r) => { + console.error(r); + + process.exit(1); + }); + + const report = { + taxProviders: [] as string[], + taxChannels: [] as string[], + none: [] as string[], + }; + + for (const env of allEnvs) { + let isTaxProvidersMigrated = false; + let isTaxChannelsMigrated = false; + + console.log("Working on env: ", env.saleorApiUrl); + + const metadataManager = getMetadataManagerForEnv(env.saleorApiUrl, env.token, env.appId); + + const taxProvidersMigrationManager = new TaxProvidersV1toV2MigrationManager( + metadataManager, + env.saleorApiUrl, + { mode: "report" } + ); + + const taxProvidersMigratedConfig = await taxProvidersMigrationManager.migrateIfNeeded(); + + if (taxProvidersMigratedConfig) { + console.log("Config migrated", taxProvidersMigratedConfig); + isTaxProvidersMigrated = true; + } + + const taxChannelsMigrationManager = new TaxChannelsV1toV2MigrationManager( + metadataManager, + env.saleorApiUrl, + { mode: "report" } + ); + + const taxChannelsMigratedConfig = await taxChannelsMigrationManager.migrateIfNeeded(); + + if (taxChannelsMigratedConfig) { + console.log("Config migrated", taxChannelsMigratedConfig); + isTaxChannelsMigrated = true; + } + + if (isTaxProvidersMigrated) { + report.taxProviders.push(env.saleorApiUrl); + } + + if (isTaxChannelsMigrated) { + report.taxChannels.push(env.saleorApiUrl); + } + + if (!isTaxProvidersMigrated && !isTaxChannelsMigrated) { + report.none.push(env.saleorApiUrl); + } + } + + console.log("Report", report); +}; + +runReport(); diff --git a/apps/taxes/scripts/migrations/tax-channels-metadata-manager-v1.ts b/apps/taxes/scripts/migrations/tax-channels-metadata-manager-v1.ts new file mode 100644 index 0000000..5f2cf14 --- /dev/null +++ b/apps/taxes/scripts/migrations/tax-channels-metadata-manager-v1.ts @@ -0,0 +1,32 @@ +// TODO: MIGRATION CODE FROM CONFIG VERSION V1. REMOVE THIS FILE AFTER MIGRATION + +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; +import { ChannelsV1 } from "./channels-config-schema-v1"; + +export class TaxChannelsPrivateMetadataManagerV1 { + private metadataKey = "tax-channels"; + + constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {} + + getConfig(): Promise { + return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => { + if (!data) { + return data; + } + + try { + return JSON.parse(data); + } catch (e) { + throw new Error("Invalid metadata value, cant be parsed"); + } + }); + } + + setConfig(config: ChannelsV1): Promise { + return this.metadataManager.set({ + key: this.metadataKey, + value: JSON.stringify(config), + domain: this.saleorApiUrl, + }); + } +} diff --git a/apps/taxes/scripts/migrations/tax-channels-metadata-manager-v2.ts b/apps/taxes/scripts/migrations/tax-channels-metadata-manager-v2.ts new file mode 100644 index 0000000..acadc8c --- /dev/null +++ b/apps/taxes/scripts/migrations/tax-channels-metadata-manager-v2.ts @@ -0,0 +1,32 @@ +// TODO: MIGRATION CODE FROM CONFIG VERSION V1. REMOVE THIS FILE AFTER MIGRATION + +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; +import { ChannelsV2 } from "./channels-config-schema-v2"; + +export class TaxChannelsPrivateMetadataManagerV2 { + private metadataKey = "tax-channels-v2"; + + constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {} + + getConfig(): Promise { + return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => { + if (!data) { + return data; + } + + try { + return JSON.parse(data); + } catch (e) { + throw new Error("Invalid metadata value, cant be parsed"); + } + }); + } + + setConfig(config: ChannelsV2): Promise { + return this.metadataManager.set({ + key: this.metadataKey, + value: JSON.stringify(config), + domain: this.saleorApiUrl, + }); + } +} diff --git a/apps/taxes/scripts/migrations/tax-channels-migration-v1-to-v2.ts b/apps/taxes/scripts/migrations/tax-channels-migration-v1-to-v2.ts new file mode 100644 index 0000000..9963ff2 --- /dev/null +++ b/apps/taxes/scripts/migrations/tax-channels-migration-v1-to-v2.ts @@ -0,0 +1,55 @@ +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; +import { Logger, createLogger } from "../../src/lib/logger"; +import { TaxChannelsPrivateMetadataManagerV1 } from "./tax-channels-metadata-manager-v1"; +import { TaxChannelsPrivateMetadataManagerV2 } from "./tax-channels-metadata-manager-v2"; +import { TaxChannelsTransformV1toV2 } from "./tax-channels-transform-v1-to-v2"; + +export class TaxChannelsV1toV2MigrationManager { + private logger: Logger; + constructor( + private metadataManager: SettingsManager, + private saleorApiUrl: string, + private options: { mode: "report" | "migrate" } = { mode: "migrate" } + ) { + this.logger = createLogger({ + location: "TaxChannelsV1toV2MigrationManager", + }); + } + + async migrateIfNeeded() { + const taxChannelsManagerV1 = new TaxChannelsPrivateMetadataManagerV1( + this.metadataManager, + this.saleorApiUrl + ); + + const taxChannelsManagerV2 = new TaxChannelsPrivateMetadataManagerV2( + this.metadataManager, + this.saleorApiUrl + ); + + const currentConfig = await taxChannelsManagerV2.getConfig(); + + if (currentConfig) { + this.logger.info("Migration is not necessary, we have current config."); + return currentConfig; + } + + const previousChannelConfig = await taxChannelsManagerV1.getConfig(); + + if (!previousChannelConfig) { + this.logger.info("Previous config not found. Migration not possible."); + return; + } + + this.logger.info("Previous config found. Migrating..."); + + const transformer = new TaxChannelsTransformV1toV2(); + const nextConfig = transformer.transform(previousChannelConfig); + + if (this.options.mode === "migrate") { + await taxChannelsManagerV2.setConfig(nextConfig); + } + + return nextConfig; + } +} diff --git a/apps/taxes/scripts/migrations/tax-channels-transform-v1-to-v2.test.ts b/apps/taxes/scripts/migrations/tax-channels-transform-v1-to-v2.test.ts new file mode 100644 index 0000000..8e8a33e --- /dev/null +++ b/apps/taxes/scripts/migrations/tax-channels-transform-v1-to-v2.test.ts @@ -0,0 +1,34 @@ +import { dummyChannelsV1Config } from "./run-generate-dummy-data"; +import { TaxChannelsTransformV1toV2 } from "./tax-channels-transform-v1-to-v2"; +import { describe, expect, it } from "vitest"; + +const transformer = new TaxChannelsTransformV1toV2(); + +describe("TaxChannelsTransformV1toV2", () => { + it("should transform v1 to v2", () => { + const result = transformer.transform(dummyChannelsV1Config); + + expect(result).toEqual([ + { + id: expect.any(String), + config: { + providerConnectionId: "24822834-1a49-4b51-8a59-579affdb772f", + slug: "default-channel", + }, + }, + { + id: expect.any(String), + config: { + providerConnectionId: "d15d9907-a3cb-42d2-9336-366d2366e91b", + slug: "channel-pln", + }, + }, + ]); + }); + + it("should return empty array if no channels are provided", () => { + const result = transformer.transform({}); + + expect(result).toEqual([]); + }); +}); diff --git a/apps/taxes/scripts/migrations/tax-channels-transform-v1-to-v2.ts b/apps/taxes/scripts/migrations/tax-channels-transform-v1-to-v2.ts new file mode 100644 index 0000000..987e4af --- /dev/null +++ b/apps/taxes/scripts/migrations/tax-channels-transform-v1-to-v2.ts @@ -0,0 +1,20 @@ +import { createId } from "../../src/lib/utils"; +import { ChannelsV1 } from "./channels-config-schema-v1"; +import { ChannelsV2 } from "./channels-config-schema-v2"; + +export class TaxChannelsTransformV1toV2 { + transform(channels: ChannelsV1): ChannelsV2 { + return Object.keys(channels).map((slug) => { + const channel = channels[slug]; + + return { + // * There was no id in v1, so we need to generate it + id: createId(), + config: { + providerConnectionId: channel.providerInstanceId, + slug, + }, + }; + }); + } +} diff --git a/apps/taxes/scripts/migrations/tax-providers-config-schema-v1.ts b/apps/taxes/scripts/migrations/tax-providers-config-schema-v1.ts new file mode 100644 index 0000000..2bcb6de --- /dev/null +++ b/apps/taxes/scripts/migrations/tax-providers-config-schema-v1.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +const avataxConfigSchema = z.object({ + name: z.string().min(1, { message: "Name requires at least one character." }), + username: z.string().min(1, { message: "Username requires at least one character." }), + password: z.string().min(1, { message: "Password requires at least one character." }), + isSandbox: z.boolean(), + companyCode: z.string().optional(), + isAutocommit: z.boolean(), + shippingTaxCode: z.string().optional(), +}); + +const avataxInstanceConfigV1Schema = z.object({ + id: z.string(), + provider: z.literal("avatax"), + config: avataxConfigSchema, +}); + +export type AvataxInstanceConfigV1 = z.infer; + +const taxJarConfigSchema = z.object({ + name: z.string().min(1, { message: "Name requires at least one character." }), + apiKey: z.string().min(1, { message: "API Key requires at least one character." }), + isSandbox: z.boolean(), +}); + +const taxJarInstanceConfigV1Schema = z.object({ + id: z.string(), + provider: z.literal("taxjar"), + config: taxJarConfigSchema, +}); + +export type TaxJarInstanceConfigV1 = z.infer; + +const taxProviderV1Schema = taxJarInstanceConfigV1Schema.or(avataxInstanceConfigV1Schema); + +export type TaxProviderV1 = z.infer; +const taxProvidersV1Schema = z.array(taxProviderV1Schema); + +export type TaxProvidersV1 = z.infer; diff --git a/apps/taxes/scripts/migrations/tax-providers-config-schema-v2.ts b/apps/taxes/scripts/migrations/tax-providers-config-schema-v2.ts new file mode 100644 index 0000000..8c7bb3d --- /dev/null +++ b/apps/taxes/scripts/migrations/tax-providers-config-schema-v2.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; +import { providerConnectionsSchema } from "../../src/modules/provider-connections/provider-connections"; + +const taxProvidersV2Schema = providerConnectionsSchema; + +export type TaxProvidersV2 = z.infer; diff --git a/apps/taxes/scripts/migrations/tax-providers-metadata-manager-v1.ts b/apps/taxes/scripts/migrations/tax-providers-metadata-manager-v1.ts new file mode 100644 index 0000000..82b3e45 --- /dev/null +++ b/apps/taxes/scripts/migrations/tax-providers-metadata-manager-v1.ts @@ -0,0 +1,32 @@ +// TODO: MIGRATION CODE FROM CONFIG VERSION V1. REMOVE THIS FILE AFTER MIGRATION + +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; +import { TaxProvidersV1 } from "./tax-providers-config-schema-v1"; + +export class TaxProvidersPrivateMetadataManagerV1 { + private metadataKey = "tax-providers"; + + constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {} + + getConfig(): Promise { + return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => { + if (!data) { + return data; + } + + try { + return JSON.parse(data); + } catch (e) { + throw new Error("Invalid metadata value, cant be parsed"); + } + }); + } + + setConfig(config: TaxProvidersV1): Promise { + return this.metadataManager.set({ + key: this.metadataKey, + value: JSON.stringify(config), + domain: this.saleorApiUrl, + }); + } +} diff --git a/apps/taxes/scripts/migrations/tax-providers-metadata-manager-v2.ts b/apps/taxes/scripts/migrations/tax-providers-metadata-manager-v2.ts new file mode 100644 index 0000000..e2861fc --- /dev/null +++ b/apps/taxes/scripts/migrations/tax-providers-metadata-manager-v2.ts @@ -0,0 +1,33 @@ +// TODO: MIGRATION CODE FROM CONFIG VERSION V1. REMOVE THIS FILE AFTER MIGRATION + +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; +import { TaxProvidersV2 } from "./tax-providers-config-schema-v2"; +import { TAX_PROVIDER_KEY } from "../../src/modules/provider-connections/public-provider-connections.service"; + +export class TaxProvidersPrivateMetadataManagerV2 { + private metadataKey = TAX_PROVIDER_KEY; + + constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {} + + getConfig(): Promise { + return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => { + if (!data) { + return data; + } + + try { + return JSON.parse(data); + } catch (e) { + throw new Error("Invalid metadata value, cant be parsed"); + } + }); + } + + setConfig(config: TaxProvidersV2): Promise { + return this.metadataManager.set({ + key: this.metadataKey, + value: JSON.stringify(config), + domain: this.saleorApiUrl, + }); + } +} diff --git a/apps/taxes/scripts/migrations/tax-providers-migration-v1-to-v2.ts b/apps/taxes/scripts/migrations/tax-providers-migration-v1-to-v2.ts new file mode 100644 index 0000000..68bc71c --- /dev/null +++ b/apps/taxes/scripts/migrations/tax-providers-migration-v1-to-v2.ts @@ -0,0 +1,66 @@ +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; +import { Logger, createLogger } from "../../src/lib/logger"; +import { TaxProvidersPrivateMetadataManagerV1 } from "./tax-providers-metadata-manager-v1"; +import { TaxProvidersPrivateMetadataManagerV2 } from "./tax-providers-metadata-manager-v2"; +import { TaxProvidersV1ToV2Transformer } from "./tax-providers-transform-v1-to-v2"; +import { TaxChannelsPrivateMetadataManagerV1 } from "./tax-channels-metadata-manager-v1"; + +export class TaxProvidersV1toV2MigrationManager { + private logger: Logger; + constructor( + private metadataManager: SettingsManager, + private saleorApiUrl: string, + private options: { mode: "report" | "migrate" } = { mode: "migrate" } + ) { + this.logger = createLogger({ + location: "TaxProvidersV1toV2MigrationManager", + }); + } + + async migrateIfNeeded() { + const taxProvidersManagerV1 = new TaxProvidersPrivateMetadataManagerV1( + this.metadataManager, + this.saleorApiUrl + ); + const taxProvidersManagerV2 = new TaxProvidersPrivateMetadataManagerV2( + this.metadataManager, + this.saleorApiUrl + ); + + const taxChannelsManagerV1 = new TaxChannelsPrivateMetadataManagerV1( + this.metadataManager, + this.saleorApiUrl + ); + + const currentTaxProvidersConfig = await taxProvidersManagerV2.getConfig(); + + if (currentTaxProvidersConfig) { + this.logger.info("Migration is not necessary, the config is up to date."); + return; + } + + this.logger.info("Current config not found."); + + const previousTaxProvidersConfig = await taxProvidersManagerV1.getConfig(); + const previousChannelConfig = await taxChannelsManagerV1.getConfig(); + + if (!previousTaxProvidersConfig || !previousChannelConfig) { + this.logger.info( + { previousChannelConfig, previousTaxProvidersConfig }, + "Previous config not found. Migration not possible." + ); + return; + } + + this.logger.info("Previous config found. Migrating..."); + + const transformer = new TaxProvidersV1ToV2Transformer(); + const nextConfig = transformer.transform(previousTaxProvidersConfig, previousChannelConfig); + + if (this.options.mode === "migrate") { + await taxProvidersManagerV2.setConfig(nextConfig); + } + + return nextConfig; + } +} diff --git a/apps/taxes/scripts/migrations/tax-providers-transform-v1-to-v2.test.ts b/apps/taxes/scripts/migrations/tax-providers-transform-v1-to-v2.test.ts new file mode 100644 index 0000000..d746ad7 --- /dev/null +++ b/apps/taxes/scripts/migrations/tax-providers-transform-v1-to-v2.test.ts @@ -0,0 +1,60 @@ +import { dummyChannelsV1Config, dummyTaxProvidersV1Config } from "./run-generate-dummy-data"; +import { TaxProvidersV1ToV2Transformer } from "./tax-providers-transform-v1-to-v2"; +import { describe, expect, it } from "vitest"; + +const transformer = new TaxProvidersV1ToV2Transformer(); + +describe("TaxProvidersV1ToV2Transformer", () => { + it("should transform v1 to v2", () => { + const result = transformer.transform(dummyTaxProvidersV1Config, dummyChannelsV1Config); + + expect(result).toEqual([ + { + id: "24822834-1a49-4b51-8a59-579affdb772f", + provider: "avatax", + config: { + name: "Avatalara1", + isSandbox: true, + isAutocommit: true, + credentials: { + username: "username", + password: "password", + }, + companyCode: "companyCode", + shippingTaxCode: "shippingTaxCode", + address: { + city: "San Francisco", + country: "US", + state: "CA", + street: "Sesame Street", + zip: "10001", + }, + }, + }, + { + id: "d15d9907-a3cb-42d2-9336-366d2366e91b", + provider: "taxjar", + config: { + name: "TaxJar1", + isSandbox: true, + credentials: { + apiKey: "apiKey", + }, + address: { + city: "San Francisco", + country: "US", + state: "CA", + street: "Sesame Street", + zip: "10001", + }, + }, + }, + ]); + }); + + it("should return empty array if no channels and providers are provided", () => { + const result = transformer.transform([], {}); + + expect(result).toEqual([]); + }); +}); diff --git a/apps/taxes/scripts/migrations/tax-providers-transform-v1-to-v2.ts b/apps/taxes/scripts/migrations/tax-providers-transform-v1-to-v2.ts new file mode 100644 index 0000000..6bc5b9c --- /dev/null +++ b/apps/taxes/scripts/migrations/tax-providers-transform-v1-to-v2.ts @@ -0,0 +1,91 @@ +import { AvataxConnection } from "../../src/modules/avatax/avatax-connection-schema"; +import { TaxJarConnection } from "../../src/modules/taxjar/taxjar-connection-schema"; +import { ChannelV1, ChannelsV1 } from "./channels-config-schema-v1"; +import { + AvataxInstanceConfigV1, + TaxJarInstanceConfigV1, + TaxProvidersV1, +} from "./tax-providers-config-schema-v1"; +import { TaxProvidersV2 } from "./tax-providers-config-schema-v2"; + +export class TaxProvidersV1ToV2Transformer { + private findTaxProviderChannelConfig = (id: string, channelsConfig: ChannelsV1): ChannelV1 => { + const channel = Object.values(channelsConfig).find( + (channel) => channel.providerInstanceId === id + ); + + if (!channel) { + throw new Error(`Channel with id ${id} not found`); + } + + return channel; + }; + + private transformAvataxInstance = ( + instance: AvataxInstanceConfigV1, + channel: ChannelV1 + ): AvataxConnection => { + return { + id: instance.id, + provider: "avatax", + config: { + name: instance.config.name, + address: { + city: channel.address.city, + country: channel.address.country, + state: channel.address.state, + street: channel.address.street, + zip: channel.address.zip, + }, + credentials: { + password: instance.config.password, + username: instance.config.username, + }, + isAutocommit: instance.config.isAutocommit, + isSandbox: instance.config.isSandbox, + companyCode: instance.config.companyCode, + shippingTaxCode: instance.config.shippingTaxCode, + }, + }; + }; + + private transformTaxJarInstance = ( + instance: TaxJarInstanceConfigV1, + channel: ChannelV1 + ): TaxJarConnection => { + return { + id: instance.id, + provider: "taxjar", + config: { + name: instance.config.name, + address: { + city: channel.address.city, + country: channel.address.country, + state: channel.address.state, + street: channel.address.street, + zip: channel.address.zip, + }, + credentials: { + apiKey: instance.config.apiKey, + }, + isSandbox: instance.config.isSandbox, + }, + }; + }; + + transform = (taxProvidersConfig: TaxProvidersV1, channelsConfig: ChannelsV1): TaxProvidersV2 => { + return taxProvidersConfig.map((instance) => { + const channel = this.findTaxProviderChannelConfig(instance.id, channelsConfig); + + if (instance.provider === "avatax") { + return this.transformAvataxInstance(instance, channel); + } + + if (instance.provider === "taxjar") { + return this.transformTaxJarInstance(instance, channel); + } + + throw new Error(`Unknown provider `); + }); + }; +} diff --git a/apps/taxes/sentry.client.config.js b/apps/taxes/sentry.client.config.js deleted file mode 100644 index 09f7c52..0000000 --- a/apps/taxes/sentry.client.config.js +++ /dev/null @@ -1,17 +0,0 @@ -// This file configures the initialization of Sentry on the browser. -// The config you add here will be used whenever a page is visited. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -import * as Sentry from '@sentry/nextjs'; - -const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; - -Sentry.init({ - dsn: SENTRY_DSN, - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1.0, - // ... - // Note: if you want to override the automatic release value, do not set a - // `release` value here - use the environment variable `SENTRY_RELEASE`, so - // that it will also get attached to your source maps -}); diff --git a/apps/taxes/sentry.client.config.ts b/apps/taxes/sentry.client.config.ts new file mode 100644 index 0000000..3a23c6d --- /dev/null +++ b/apps/taxes/sentry.client.config.ts @@ -0,0 +1,34 @@ +/* + * This file configures the initialization of Sentry on the client. + * The config you add here will be used whenever a users loads a page in their browser. + * https://docs.sentry.io/platforms/javascript/guides/nextjs/ + */ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + replaysOnErrorSampleRate: 1.0, + + /* + * This sets the sample rate to be 10%. You may want this to be 100% while + * in development and sample at a lower rate in production + */ + replaysSessionSampleRate: 0.1, + + // You can remove this option if you're not planning to use the Sentry Session Replay feature: + integrations: [ + new Sentry.Replay({ + // Additional Replay configuration goes in here, for example: + maskAllText: true, + blockAllMedia: true, + }), + ], +}); diff --git a/apps/taxes/sentry.edge.config.js b/apps/taxes/sentry.edge.config.js deleted file mode 100644 index fcf9cb8..0000000 --- a/apps/taxes/sentry.edge.config.js +++ /dev/null @@ -1,17 +0,0 @@ -// This file configures the initialization of Sentry on the server. -// The config you add here will be used whenever middleware or an Edge route handles a request. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -import * as Sentry from '@sentry/nextjs'; - -const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; - -Sentry.init({ - dsn: SENTRY_DSN, - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1.0, - // ... - // Note: if you want to override the automatic release value, do not set a - // `release` value here - use the environment variable `SENTRY_RELEASE`, so - // that it will also get attached to your source maps -}); diff --git a/apps/taxes/sentry.edge.config.ts b/apps/taxes/sentry.edge.config.ts new file mode 100644 index 0000000..1f52245 --- /dev/null +++ b/apps/taxes/sentry.edge.config.ts @@ -0,0 +1,18 @@ +/* + * This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). + * The config you add here will be used whenever one of the edge features is loaded. + * Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. + * https://docs.sentry.io/platforms/javascript/guides/nextjs/ + */ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); diff --git a/apps/taxes/sentry.server.config.js b/apps/taxes/sentry.server.config.js deleted file mode 100644 index 990cf22..0000000 --- a/apps/taxes/sentry.server.config.js +++ /dev/null @@ -1,17 +0,0 @@ -// This file configures the initialization of Sentry on the server. -// The config you add here will be used whenever the server handles a request. -// https://docs.sentry.io/platforms/javascript/guides/nextjs/ - -import * as Sentry from '@sentry/nextjs'; - -const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; - -Sentry.init({ - dsn: SENTRY_DSN, - // Adjust this value in production, or use tracesSampler for greater control - tracesSampleRate: 1.0, - // ... - // Note: if you want to override the automatic release value, do not set a - // `release` value here - use the environment variable `SENTRY_RELEASE`, so - // that it will also get attached to your source maps -}); diff --git a/apps/taxes/sentry.server.config.ts b/apps/taxes/sentry.server.config.ts new file mode 100644 index 0000000..979384c --- /dev/null +++ b/apps/taxes/sentry.server.config.ts @@ -0,0 +1,17 @@ +/* + * This file configures the initialization of Sentry on the server. + * The config you add here will be used whenever the server handles a request. + * https://docs.sentry.io/platforms/javascript/guides/nextjs/ + */ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); diff --git a/apps/taxes/src/assets/index.ts b/apps/taxes/src/assets/index.ts index 0340cfd..1f7650c 100644 --- a/apps/taxes/src/assets/index.ts +++ b/apps/taxes/src/assets/index.ts @@ -1,2 +1,3 @@ export { default as AvataxIcon } from "./avatax-icon.svg"; export { default as TaxJarIcon } from "./taxjar-icon.svg"; +export { default as StripeTaxIcon } from "./stripe-icon.svg"; diff --git a/apps/taxes/src/assets/stripe-icon.svg b/apps/taxes/src/assets/stripe-icon.svg new file mode 100644 index 0000000..2391f52 --- /dev/null +++ b/apps/taxes/src/assets/stripe-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/taxes/src/lib/no-ssr-wrapper.tsx b/apps/taxes/src/lib/no-ssr-wrapper.tsx deleted file mode 100644 index 4917e33..0000000 --- a/apps/taxes/src/lib/no-ssr-wrapper.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React, { PropsWithChildren } from "react"; -import dynamic from "next/dynamic"; - -const Wrapper = (props: PropsWithChildren<{}>) => {props.children}; - -export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), { - ssr: false, -}); diff --git a/apps/taxes/src/lib/obfuscator.test.ts b/apps/taxes/src/lib/obfuscator.test.ts new file mode 100644 index 0000000..e40787f --- /dev/null +++ b/apps/taxes/src/lib/obfuscator.test.ts @@ -0,0 +1,22 @@ +import { Obfuscator } from "./obfuscator"; +import { describe, expect, it } from "vitest"; +const obfuscator = new Obfuscator(); + +describe("Obfuscator", () => { + describe("obfuscate", () => { + it("obfuscates all but the last 4 characters of a string", () => { + expect(obfuscator.obfuscate("1234567890")).toBe("******7890"); + }); + it("returns asterisks even if the string is shorter than 4 characters", () => { + expect(obfuscator.obfuscate("123")).toBe("***"); + }); + }); + describe("isObfuscated", () => { + it("returns true if the string contains 4 asterisks", () => { + expect(obfuscator.isObfuscated("1234****")).toBe(true); + }); + it("returns false if the string does not contain 4 asterisks", () => { + expect(obfuscator.isObfuscated("1234567890")).toBe(false); + }); + }); +}); diff --git a/apps/taxes/src/lib/obfuscator.ts b/apps/taxes/src/lib/obfuscator.ts new file mode 100644 index 0000000..e750a12 --- /dev/null +++ b/apps/taxes/src/lib/obfuscator.ts @@ -0,0 +1,16 @@ +export class Obfuscator { + obfuscate = (value: string) => { + if (value.length < 4) { + return "*".repeat(value.length); + } + + return value.replace(/.(?=.{4})/g, "*"); + }; + + /* + * // ! What if the user password contains "****"? We shouldn't rely on the characters to verify the obfuscation, + * // ! but rather on the context. For example, when updating a provider configuration, + * // ! we could check if the value has changed. If it's the same as returned from the server, then it's obfuscated. + */ + isObfuscated = (value: string) => value.includes("****"); +} diff --git a/apps/taxes/src/lib/theme-synchronizer.test.tsx b/apps/taxes/src/lib/theme-synchronizer.test.tsx deleted file mode 100644 index c2d8b70..0000000 --- a/apps/taxes/src/lib/theme-synchronizer.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { AppBridgeState } from "@saleor/app-sdk/app-bridge"; -import { render, waitFor } from "@testing-library/react"; -import { ThemeSynchronizer } from "./theme-synchronizer"; - -const appBridgeState: AppBridgeState = { - ready: true, - token: "token", - domain: "some-domain.saleor.cloud", - theme: "dark", - path: "/", - locale: "en", - id: "app-id", - saleorApiUrl: "https://some-domain.saleor.cloud/graphql/", -}; - -const mockThemeChange = vi.fn(); - -vi.mock("@saleor/app-sdk/app-bridge", () => { - return { - useAppBridge() { - return { - appBridgeState: appBridgeState, - }; - }, - }; -}); - -vi.mock("@saleor/macaw-ui", () => { - return { - useTheme() { - return { - setTheme: mockThemeChange, - themeType: "light", - }; - }, - }; -}); - -describe("ThemeSynchronizer", () => { - it("Updates MacawUI theme when AppBridgeState theme changes", () => { - render(); - - return waitFor(() => { - expect(mockThemeChange).toHaveBeenCalledWith("dark"); - }); - }); -}); diff --git a/apps/taxes/src/lib/theme-synchronizer.tsx b/apps/taxes/src/lib/theme-synchronizer.tsx index 02b3b61..3386f49 100644 --- a/apps/taxes/src/lib/theme-synchronizer.tsx +++ b/apps/taxes/src/lib/theme-synchronizer.tsx @@ -1,28 +1,25 @@ import { useAppBridge } from "@saleor/app-sdk/app-bridge"; -import { useTheme } from "@saleor/macaw-ui"; -import { memo, useEffect } from "react"; +import { useTheme } from "@saleor/macaw-ui/next"; +import { useEffect } from "react"; -/** - * Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard, - * Macaw must be informed about this change from AppBridge. - * - * If you are not using Macaw, you can remove this. - */ -function _ThemeSynchronizer() { +// todo move to shared +export function ThemeSynchronizer() { const { appBridgeState } = useAppBridge(); - const { setTheme, themeType } = useTheme(); + const { setTheme } = useTheme(); useEffect(() => { if (!setTheme || !appBridgeState?.theme) { return; } - if (themeType !== appBridgeState?.theme) { - setTheme(appBridgeState.theme); + if (appBridgeState.theme === "light") { + setTheme("defaultLight"); } - }, [appBridgeState?.theme, setTheme, themeType]); + + if (appBridgeState.theme === "dark") { + setTheme("defaultDark"); + } + }, [appBridgeState?.theme, setTheme]); return null; } - -export const ThemeSynchronizer = memo(_ThemeSynchronizer); diff --git a/apps/taxes/src/lib/utils.ts b/apps/taxes/src/lib/utils.ts index 70d8fac..21c6d5c 100644 --- a/apps/taxes/src/lib/utils.ts +++ b/apps/taxes/src/lib/utils.ts @@ -1,7 +1,3 @@ const { randomUUID } = require("crypto"); // Added in: node v14.17.0 export const createId = (): string => randomUUID(); - -export const obfuscateSecret = (value: string) => value.replace(/.(?=.{4})/g, "*"); - -export const isObfuscated = (value: string) => value.includes("****"); diff --git a/apps/taxes/src/modules/app/app-configurator.ts b/apps/taxes/src/modules/app/app-configurator.ts deleted file mode 100644 index 7557d93..0000000 --- a/apps/taxes/src/modules/app/app-configurator.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { SettingsManager } from "@saleor/app-sdk/settings-manager"; - -export interface AppConfigurator> { - setConfig(config: TConfig): Promise; - getConfig(): Promise; -} - -export class PrivateMetadataAppConfigurator> - implements AppConfigurator -{ - constructor( - private metadataManager: SettingsManager, - private saleorApiUrl: string, - private metadataKey: string - ) { - this.metadataKey = metadataKey; - } - - getConfig(): Promise { - return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => { - if (!data) { - return data; - } - - try { - return JSON.parse(data); - } catch (e) { - throw new Error("Invalid metadata value, cant be parsed"); - } - }); - } - - setConfig(config: TConfig): Promise { - return this.metadataManager.set({ - key: this.metadataKey, - value: JSON.stringify(config), - domain: this.saleorApiUrl, - }); - } -} diff --git a/apps/taxes/src/modules/app/get-app-config.test.ts b/apps/taxes/src/modules/app/get-app-config.test.ts index 70edbb6..21bcdbd 100644 --- a/apps/taxes/src/modules/app/get-app-config.test.ts +++ b/apps/taxes/src/modules/app/get-app-config.test.ts @@ -1,12 +1,12 @@ import { encrypt } from "@saleor/app-sdk/settings-manager"; import { getAppConfig } from "./get-app-config"; import { describe, expect, it, vi } from "vitest"; -import { ProvidersConfig } from "../providers-configuration/providers-config"; +import { ProviderConnections } from "../provider-connections/provider-connections"; import { MetadataItem } from "../../../generated/graphql"; -import { ChannelsConfig } from "../channels-configuration/channels-config"; +import { ChannelsConfig } from "../channel-configuration/channel-config"; const mockedSecretKey = "test_secret_key"; -const mockedProviders: ProvidersConfig = [ +const mockedProviders: ProviderConnections = [ { provider: "avatax", id: "1", @@ -15,9 +15,18 @@ const mockedProviders: ProvidersConfig = [ isAutocommit: false, isSandbox: true, name: "avatax-1", - password: "avatax-password", - username: "avatax-username", shippingTaxCode: "FR000000", + credentials: { + password: "avatax-password", + username: "avatax-username", + }, + address: { + city: "New York", + country: "US", + state: "NY", + street: "123 Main St", + zip: "10001", + }, }, }, { @@ -25,26 +34,31 @@ const mockedProviders: ProvidersConfig = [ id: "2", config: { name: "taxjar-1", - apiKey: "taxjar-api-key", isSandbox: true, + credentials: { + apiKey: "taxjar-api-key", + }, + address: { + city: "New York", + country: "US", + state: "NY", + street: "123 Main St", + zip: "10001", + }, }, }, ]; const mockedEncryptedProviders = encrypt(JSON.stringify(mockedProviders), mockedSecretKey); -const mockedChannels: ChannelsConfig = { - "default-channel": { - address: { - city: "New York", - country: "US", - state: "NY", - street: "123 Main St", - zip: "10001", +const mockedChannels: ChannelsConfig = [ + { + id: "1", + config: { + providerConnectionId: "1", + slug: "default-channel", }, - enabled: true, - providerInstanceId: "1", }, -}; +]; const mockedEncryptedChannels = encrypt(JSON.stringify(mockedChannels), mockedSecretKey); @@ -62,17 +76,17 @@ const mockedMetadata: MetadataItem[] = [ vi.stubEnv("SECRET_KEY", mockedSecretKey); describe("getAppConfig", () => { - it("returns empty providers and channels config when no metadata", () => { - const { providers, channels } = getAppConfig([]); + it("returns empty providerConnections and channels config when no metadata", () => { + const { providerConnections, channels } = getAppConfig([]); - expect(providers).toEqual([]); + expect(providerConnections).toEqual([]); expect(channels).toEqual({}); }); - it("returns decrypted providers and channels config when metadata provided", () => { - const { providers, channels } = getAppConfig(mockedMetadata); + it("returns decrypted providerConnections and channels config when metadata provided", () => { + const { providerConnections, channels } = getAppConfig(mockedMetadata); - expect(providers).toEqual(mockedProviders); + expect(providerConnections).toEqual(mockedProviders); expect(channels).toEqual(mockedChannels); }); }); diff --git a/apps/taxes/src/modules/app/get-app-config.ts b/apps/taxes/src/modules/app/get-app-config.ts index 0c44927..fd95db9 100644 --- a/apps/taxes/src/modules/app/get-app-config.ts +++ b/apps/taxes/src/modules/app/get-app-config.ts @@ -1,10 +1,13 @@ import { decrypt } from "@saleor/app-sdk/settings-manager"; import { MetadataItem } from "../../../generated/graphql"; -import { ChannelsConfig, channelsSchema } from "../channels-configuration/channels-config"; -import { ProvidersConfig, providersSchema } from "../providers-configuration/providers-config"; +import { ChannelsConfig, channelsSchema } from "../channel-configuration/channel-config"; +import { + ProviderConnections, + providerConnectionsSchema, +} from "../provider-connections/provider-connections"; export const getAppConfig = (metadata: MetadataItem[]) => { - let providersConfig = [] as ProvidersConfig; + let providerConnections = [] as ProviderConnections; let channelsConfig = {} as ChannelsConfig; const secretKey = process.env.SECRET_KEY; @@ -21,10 +24,10 @@ export const getAppConfig = (metadata: MetadataItem[]) => { const decrypted = decrypt(item.value, secretKey); const parsed = JSON.parse(decrypted); - const providersValidation = providersSchema.safeParse(parsed); + const providerConnectionValidation = providerConnectionsSchema.safeParse(parsed); - if (providersValidation.success) { - providersConfig = providersValidation.data; + if (providerConnectionValidation.success) { + providerConnections = providerConnectionValidation.data; return; } @@ -36,5 +39,5 @@ export const getAppConfig = (metadata: MetadataItem[]) => { } }); - return { providers: providersConfig, channels: channelsConfig }; + return { providerConnections: providerConnections, channels: channelsConfig }; }; diff --git a/apps/taxes/src/modules/app/metadata-manager.ts b/apps/taxes/src/modules/app/metadata-manager.ts index a7747db..988177c 100644 --- a/apps/taxes/src/modules/app/metadata-manager.ts +++ b/apps/taxes/src/modules/app/metadata-manager.ts @@ -1,24 +1,41 @@ import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager"; -import { Client } from "urql"; +import { Client, gql } from "urql"; import { FetchAppDetailsDocument, FetchAppDetailsQuery, UpdateMetadataDocument, } from "../../../generated/graphql"; -import { createLogger } from "../../lib/logger"; + +gql` + mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) { + updatePrivateMetadata(id: $id, input: $input) { + item { + privateMetadata { + key + value + } + } + } + } +`; + +gql` + query FetchAppDetails { + app { + id + privateMetadata { + key + value + } + } + } +`; export async function fetchAllMetadata(client: Client): Promise { - const logger = createLogger({ service: "fetchAllMetadata" }); - - logger.debug("Fetching metadata from Saleor"); - const { error, data } = await client .query(FetchAppDetailsDocument, {}) .toPromise(); - // * `metadata` name is required for secrets censorship - logger.debug({ error, metadata: data }, "Metadata fetched"); - if (error) { return []; } @@ -26,29 +43,7 @@ export async function fetchAllMetadata(client: Client): Promise return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || []; } -export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) { - const logger = createLogger({ service: "mutateMetadata" }); - - logger.debug({ metadata }, "Mutating metadata"); - // to update the metadata, ID is required - const { error: idQueryError, data: idQueryData } = await client - .query(FetchAppDetailsDocument, {}) - .toPromise(); - - logger.debug({ error: idQueryError, data: idQueryData }, "Metadata mutated"); - - if (idQueryError) { - throw new Error( - "Could not fetch the app id. Please check if auth data for the client are valid." - ); - } - - const appId = idQueryData?.app?.id; - - if (!appId) { - throw new Error("Could not fetch the app ID"); - } - +export async function mutateMetadata(client: Client, metadata: MetadataEntry[], appId: string) { const { error: mutationError, data: mutationData } = await client .mutation(UpdateMetadataDocument, { id: appId, @@ -68,8 +63,8 @@ export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) ); } -export const createSettingsManager = (client: Client) => { - /** +export const createSettingsManager = (client: Client, appId: string) => { + /* * EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory. * We recommend it for production, because all values are encrypted. * If your use case require plain text values, you can use MetadataManager. @@ -78,6 +73,6 @@ export const createSettingsManager = (client: Client) => { // Secret key should be randomly created for production and set as environment variable encryptionKey: process.env.SECRET_KEY!, fetchMetadata: () => fetchAllMetadata(client), - mutateMetadata: (metadata) => mutateMetadata(client, metadata), + mutateMetadata: (metadata) => mutateMetadata(client, metadata, appId), }); }; diff --git a/apps/taxes/src/modules/app/webhook-response.ts b/apps/taxes/src/modules/app/webhook-response.ts index d7bd56d..57ac629 100644 --- a/apps/taxes/src/modules/app/webhook-response.ts +++ b/apps/taxes/src/modules/app/webhook-response.ts @@ -9,12 +9,12 @@ export class WebhookResponse { } private returnSuccess(data?: unknown) { - this.logger.debug({ data }, "success called with:"); + this.logger.debug({ data }, "Responding to Saleor with data:"); return this.res.status(200).json(data ?? {}); } private returnError(errorMessage: string) { - this.logger.debug({ errorMessage }, "returning error:"); + this.logger.debug({ errorMessage }, "Responding to Saleor with error:"); return this.res.status(500).json({ error: errorMessage }); } diff --git a/apps/taxes/src/modules/avatax/address-factory.ts b/apps/taxes/src/modules/avatax/address-factory.ts index 74ff9d9..6068aea 100644 --- a/apps/taxes/src/modules/avatax/address-factory.ts +++ b/apps/taxes/src/modules/avatax/address-factory.ts @@ -1,8 +1,8 @@ import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo"; -import { ChannelAddress } from "../channels-configuration/channels-config"; -import { AddressFragment as SaleorAddress } from "../../../generated/graphql"; +import { AvataxConfig } from "./avatax-connection-schema"; +import { AddressFragment } from "../../../generated/graphql"; -function mapSaleorAddressToAvataxAddress(address: SaleorAddress): AvataxAddress { +function mapSaleorAddressToAvataxAddress(address: AddressFragment): AvataxAddress { return { line1: address.streetAddress1, line2: address.streetAddress2, @@ -13,7 +13,7 @@ function mapSaleorAddressToAvataxAddress(address: SaleorAddress): AvataxAddress }; } -function mapChannelAddressToAvataxAddress(address: ChannelAddress): AvataxAddress { +function mapChannelAddressToAvataxAddress(address: AvataxConfig["address"]): AvataxAddress { return { line1: address.street, city: address.city, diff --git a/apps/taxes/src/modules/avatax/avatax-client.ts b/apps/taxes/src/modules/avatax/avatax-client.ts index 95cead0..263e694 100644 --- a/apps/taxes/src/modules/avatax/avatax-client.ts +++ b/apps/taxes/src/modules/avatax/avatax-client.ts @@ -2,7 +2,7 @@ import Avatax from "avatax"; import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel"; import packageJson from "../../../package.json"; import { createLogger, Logger } from "../../lib/logger"; -import { AvataxConfig } from "./avatax-config"; +import { AvataxConfig } from "./avatax-connection-schema"; import { CommitTransactionModel } from "avatax/lib/models/CommitTransactionModel"; import { DocumentType } from "avatax/lib/enums/DocumentType"; import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo"; @@ -57,54 +57,22 @@ export class AvataxClient { private logger: Logger; constructor(config: AvataxConfig) { - this.logger = createLogger({ service: "AvataxClient" }); - this.logger.trace("AvataxClient constructor"); - const { username, password } = config; - const credentials = { - username, - password, - }; + this.logger = createLogger({ location: "AvataxClient" }); const settings = createAvataxSettings(config); - const avataxClient = new Avatax(settings).withSecurity(credentials); + const avataxClient = new Avatax(settings).withSecurity(config.credentials); - this.logger.trace({ client: avataxClient }, "External Avatax client created"); this.client = avataxClient; } async createTransaction({ model }: CreateTransactionArgs) { - this.logger.trace({ model }, "createTransaction called with:"); - return this.client.createTransaction({ model }); } async commitTransaction(args: CommitTransactionArgs) { - this.logger.trace(args, "commitTransaction called with:"); - return this.client.commitTransaction(args); } - async ping() { - this.logger.trace("ping called"); - try { - const result = await this.client.ping(); - - return { - authenticated: result.authenticated, - ...(!result.authenticated && { - error: "Avatax was not able to authenticate with the provided credentials.", - }), - }; - } catch (error) { - return { - authenticated: false, - error: "Avatax was not able to authenticate with the provided credentials.", - }; - } - } - async validateAddress({ address }: ValidateAddressArgs) { - this.logger.trace({ address }, "validateAddress called with:"); - return this.client.resolveAddress(address); } } diff --git a/apps/taxes/src/modules/avatax/avatax-config-mock-generator.ts b/apps/taxes/src/modules/avatax/avatax-config-mock-generator.ts new file mode 100644 index 0000000..602bd48 --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-config-mock-generator.ts @@ -0,0 +1,34 @@ +import { AvataxConfig } from "./avatax-connection-schema"; + +const defaultAvataxConfig: AvataxConfig = { + companyCode: "DEFAULT", + isAutocommit: false, + isSandbox: true, + name: "Avatax-1", + shippingTaxCode: "FR000000", + address: { + country: "US", + zip: "95008", + state: "CA", + city: "Campbell", + street: "33 N. First Street", + }, + credentials: { + password: "password", + username: "username", + }, +}; + +const testingScenariosMap = { + default: defaultAvataxConfig, +}; + +export class AvataxConfigMockGenerator { + constructor(private scenario: keyof typeof testingScenariosMap = "default") {} + + generateAvataxConfig = (overrides: Partial = {}): AvataxConfig => + structuredClone({ + ...testingScenariosMap[this.scenario], + ...overrides, + }); +} diff --git a/apps/taxes/src/modules/avatax/avatax-config-obfuscator.test.ts b/apps/taxes/src/modules/avatax/avatax-config-obfuscator.test.ts new file mode 100644 index 0000000..4715f00 --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-config-obfuscator.test.ts @@ -0,0 +1,61 @@ +import { AvataxConfigMockGenerator } from "./avatax-config-mock-generator"; +import { AvataxConnectionObfuscator } from "./avatax-connection-obfuscator"; +import { expect, it, describe } from "vitest"; + +const mockAvataxConfig = new AvataxConfigMockGenerator().generateAvataxConfig(); +const obfuscator = new AvataxConnectionObfuscator(); + +describe("AvataxConnectionObfuscator", () => { + it("obfuscated avatax config", () => { + const obfuscatedConfig = obfuscator.obfuscateAvataxConfig(mockAvataxConfig); + + expect(obfuscatedConfig).toEqual({ + ...mockAvataxConfig, + credentials: { + password: "****word", + username: "****name", + }, + }); + }); + it("filters out obfuscated", () => { + const obfuscatedConfig = obfuscator.obfuscateAvataxConfig(mockAvataxConfig); + const { credentials, ...rest } = obfuscatedConfig; + + const filteredConfig = obfuscator.filterOutObfuscated(obfuscatedConfig); + + expect(filteredConfig).toEqual(rest); + }); + it("filters out username when is obfuscated", () => { + const filteredConfig = obfuscator.filterOutObfuscated({ + ...mockAvataxConfig, + credentials: { + password: "password", + username: "****name", + }, + }); + + expect(filteredConfig).toEqual({ + ...mockAvataxConfig, + credentials: { + password: "password", + }, + }); + }); + + it("filters out password when is obfuscated", () => { + const filteredConfig = obfuscator.filterOutObfuscated({ + ...mockAvataxConfig, + credentials: { + password: "****word", + username: "username", + }, + }); + + expect(filteredConfig).toEqual({ + ...mockAvataxConfig, + credentials: { + username: "username", + }, + }); + }); +}); diff --git a/apps/taxes/src/modules/avatax/avatax-configuration.router.ts b/apps/taxes/src/modules/avatax/avatax-configuration.router.ts deleted file mode 100644 index c5a3a40..0000000 --- a/apps/taxes/src/modules/avatax/avatax-configuration.router.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { z } from "zod"; -import { createLogger } from "../../lib/logger"; -import { isObfuscated } from "../../lib/utils"; -import { protectedClientProcedure } from "../trpc/protected-client-procedure"; -import { router } from "../trpc/trpc-server"; -import { avataxConfigSchema, obfuscateAvataxConfig } from "./avatax-config"; -import { AvataxConfigurationService } from "./avatax-configuration.service"; - -const getInputSchema = z.object({ - id: z.string(), -}); - -const deleteInputSchema = z.object({ - id: z.string(), -}); - -const patchInputSchema = z.object({ - id: z.string(), - value: avataxConfigSchema.partial().transform((c) => { - const { username, password, ...config } = c ?? {}; - - return { - ...config, - ...(username && !isObfuscated(username) && { username }), - ...(password && !isObfuscated(password) && { password }), - }; - }), -}); - -const postInputSchema = z.object({ - value: avataxConfigSchema, -}); - -export const avataxConfigurationRouter = router({ - get: protectedClientProcedure.input(getInputSchema).query(async ({ ctx, input }) => { - const logger = createLogger({ - saleorApiUrl: ctx.saleorApiUrl, - procedure: "avataxConfigurationRouter.get", - }); - - logger.debug({ input }, "avataxConfigurationRouter.get called with:"); - - const { apiClient, saleorApiUrl } = ctx; - const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl); - - const result = await avataxConfigurationService.get(input.id); - - // * `providerInstance` name is required for secrets censorship - logger.debug({ providerInstance: result }, "avataxConfigurationRouter.get finished"); - - return { ...result, config: obfuscateAvataxConfig(result.config) }; - }), - post: protectedClientProcedure.input(postInputSchema).mutation(async ({ ctx, input }) => { - const logger = createLogger({ - saleorApiUrl: ctx.saleorApiUrl, - procedure: "avataxConfigurationRouter.post", - }); - - logger.debug({ input }, "avataxConfigurationRouter.post called with:"); - - const { apiClient, saleorApiUrl } = ctx; - const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl); - - const result = await avataxConfigurationService.post(input.value); - - logger.debug({ result }, "avataxConfigurationRouter.post finished"); - - return result; - }), - delete: protectedClientProcedure.input(deleteInputSchema).mutation(async ({ ctx, input }) => { - const logger = createLogger({ - saleorApiUrl: ctx.saleorApiUrl, - procedure: "avataxConfigurationRouter.delete", - }); - - logger.debug({ input }, "avataxConfigurationRouter.delete called with:"); - - const { apiClient, saleorApiUrl } = ctx; - const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl); - - const result = await avataxConfigurationService.delete(input.id); - - logger.debug({ result }, "avataxConfigurationRouter.delete finished"); - - return result; - }), - patch: protectedClientProcedure.input(patchInputSchema).mutation(async ({ ctx, input }) => { - const logger = createLogger({ - saleorApiUrl: ctx.saleorApiUrl, - procedure: "avataxConfigurationRouter.patch", - }); - - logger.debug({ input }, "avataxConfigurationRouter.patch called with:"); - - const { apiClient, saleorApiUrl } = ctx; - const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl); - - const result = await avataxConfigurationService.patch(input.id, input.value); - - logger.debug({ result }, "avataxConfigurationRouter.patch finished"); - - return result; - }), -}); diff --git a/apps/taxes/src/modules/avatax/avatax-configuration.service.ts b/apps/taxes/src/modules/avatax/avatax-configuration.service.ts deleted file mode 100644 index fa6a0ea..0000000 --- a/apps/taxes/src/modules/avatax/avatax-configuration.service.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Client } from "urql"; -import { createLogger, Logger } from "../../lib/logger"; -import { createSettingsManager } from "../app/metadata-manager"; -import { CrudSettingsManager } from "../crud-settings/crud-settings.service"; -import { providersSchema } from "../providers-configuration/providers-config"; -import { TAX_PROVIDER_KEY } from "../providers-configuration/public-providers-configuration-service"; -import { AvataxClient } from "./avatax-client"; -import { AvataxConfig, AvataxInstanceConfig, avataxInstanceConfigSchema } from "./avatax-config"; - -const getSchema = avataxInstanceConfigSchema; - -export class AvataxConfigurationService { - private crudSettingsManager: CrudSettingsManager; - private logger: Logger; - constructor(client: Client, saleorApiUrl: string) { - const settingsManager = createSettingsManager(client); - - this.crudSettingsManager = new CrudSettingsManager( - settingsManager, - saleorApiUrl, - TAX_PROVIDER_KEY - ); - this.logger = createLogger({ - service: "AvataxConfigurationService", - metadataKey: TAX_PROVIDER_KEY, - }); - } - - async getAll(): Promise { - this.logger.debug(".getAll called"); - const { data } = await this.crudSettingsManager.readAll(); - const validation = providersSchema.safeParse(data); - - if (!validation.success) { - this.logger.error({ error: validation.error.format() }, "Validation error while getAll"); - throw new Error(validation.error.message); - } - - const instances = validation.data.filter( - (instance) => instance.provider === "avatax" - ) as AvataxInstanceConfig[]; - - return instances; - } - - async get(id: string): Promise { - this.logger.debug(`.get called with id: ${id}`); - const { data } = await this.crudSettingsManager.read(id); - - this.logger.debug(`Fetched setting from CrudSettingsManager`); - - const validation = getSchema.safeParse(data); - - if (!validation.success) { - this.logger.error({ error: validation.error.format() }, "Validation error while get"); - throw new Error(validation.error.message); - } - - return validation.data; - } - - async post(config: AvataxConfig): Promise<{ id: string }> { - this.logger.debug(`.post called with value: ${JSON.stringify(config)}`); - const avataxClient = new AvataxClient(config); - const validation = await avataxClient.ping(); - - if (!validation.authenticated) { - this.logger.error(validation.error); - throw new Error(validation.error); - } - - const result = await this.crudSettingsManager.create({ - provider: "avatax", - config: config, - }); - - return result.data; - } - - async patch(id: string, config: Partial): Promise { - this.logger.debug(`.patch called with id: ${id} and value: ${JSON.stringify(config)}`); - const data = await this.get(id); - // omit the key "id" from the result - const { id: _, ...setting } = data; - - return this.crudSettingsManager.update(id, { - ...setting, - config: { ...setting.config, ...config }, - }); - } - - async put(id: string, config: AvataxConfig): Promise { - const data = await this.get(id); - // omit the key "id" from the result - const { id: _, ...setting } = data; - - this.logger.debug(`.put called with id: ${id} and value: ${JSON.stringify(config)}`); - return this.crudSettingsManager.update(id, { - ...setting, - config: { ...config }, - }); - } - - async delete(id: string): Promise { - this.logger.debug(`.delete called with id: ${id}`); - return this.crudSettingsManager.delete(id); - } -} diff --git a/apps/taxes/src/modules/avatax/avatax-connection-obfuscator.ts b/apps/taxes/src/modules/avatax/avatax-connection-obfuscator.ts new file mode 100644 index 0000000..739542c --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-connection-obfuscator.ts @@ -0,0 +1,44 @@ +import { Obfuscator } from "../../lib/obfuscator"; +import { AvataxConfig, AvataxConnection } from "./avatax-connection-schema"; + +export class AvataxConnectionObfuscator { + private obfuscator = new Obfuscator(); + + obfuscateAvataxConfig = (config: AvataxConfig): AvataxConfig => { + return { + ...config, + credentials: { + ...config.credentials, + username: this.obfuscator.obfuscate(config.credentials.username), + password: this.obfuscator.obfuscate(config.credentials.password), + }, + }; + }; + + obfuscateAvataxConnection = (connection: AvataxConnection): AvataxConnection => ({ + ...connection, + config: this.obfuscateAvataxConfig(connection.config), + }); + + obfuscateAvataxConnections = (connections: AvataxConnection[]): AvataxConnection[] => + connections.map((connection) => this.obfuscateAvataxConnection(connection)); + + filterOutObfuscated = (data: AvataxConfig) => { + const { credentials, ...rest } = data; + const isPasswordObfuscated = this.obfuscator.isObfuscated(credentials.password); + const isUsernameObfuscated = this.obfuscator.isObfuscated(credentials.username); + const isBothObfuscated = isPasswordObfuscated && isUsernameObfuscated; + + if (isBothObfuscated) { + return rest; + } + + return { + ...rest, + credentials: { + ...(!isPasswordObfuscated && { password: credentials.password }), + ...(!isUsernameObfuscated && { username: credentials.username }), + }, + }; + }; +} diff --git a/apps/taxes/src/modules/avatax/avatax-config.ts b/apps/taxes/src/modules/avatax/avatax-connection-schema.ts similarity index 56% rename from apps/taxes/src/modules/avatax/avatax-config.ts rename to apps/taxes/src/modules/avatax/avatax-connection-schema.ts index 4521aa6..0662c6e 100644 --- a/apps/taxes/src/modules/avatax/avatax-config.ts +++ b/apps/taxes/src/modules/avatax/avatax-connection-schema.ts @@ -1,44 +1,53 @@ import { z } from "zod"; -import { obfuscateSecret } from "../../lib/utils"; + +const addressSchema = z.object({ + country: z.string(), + zip: z.string(), + state: z.string(), + city: z.string(), + street: z.string(), +}); + +const avataxCredentialsSchema = z.object({ + username: z.string().min(1, { message: "Username requires at least one character." }), + password: z.string().min(1, { message: "Password requires at least one character." }), +}); export const avataxConfigSchema = z.object({ name: z.string().min(1, { message: "Name requires at least one character." }), - username: z.string().min(1, { message: "Username requires at least one character." }), - password: z.string().min(1, { message: "Password requires at least one character." }), isSandbox: z.boolean(), companyCode: z.string().optional(), isAutocommit: z.boolean(), shippingTaxCode: z.string().optional(), + credentials: avataxCredentialsSchema, + address: addressSchema, }); export type AvataxConfig = z.infer; export const defaultAvataxConfig: AvataxConfig = { name: "", - username: "", - password: "", companyCode: "", - isSandbox: true, + isSandbox: false, isAutocommit: false, shippingTaxCode: "", + credentials: { + username: "", + password: "", + }, + address: { + city: "", + country: "", + state: "", + street: "", + zip: "", + }, }; -export const avataxInstanceConfigSchema = z.object({ +export const avataxConnectionSchema = z.object({ id: z.string(), provider: z.literal("avatax"), config: avataxConfigSchema, }); -export type AvataxInstanceConfig = z.infer; - -export const obfuscateAvataxConfig = (config: AvataxConfig) => ({ - ...config, - username: obfuscateSecret(config.username), - password: obfuscateSecret(config.password), -}); - -export const obfuscateAvataxInstances = (instances: AvataxInstanceConfig[]) => - instances.map((instance) => ({ - ...instance, - config: obfuscateAvataxConfig(instance.config), - })); +export type AvataxConnection = z.infer; diff --git a/apps/taxes/src/modules/avatax/avatax-connection.router.ts b/apps/taxes/src/modules/avatax/avatax-connection.router.ts new file mode 100644 index 0000000..6b492d6 --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-connection.router.ts @@ -0,0 +1,100 @@ +import { z } from "zod"; +import { createLogger } from "../../lib/logger"; +import { protectedClientProcedure } from "../trpc/protected-client-procedure"; +import { router } from "../trpc/trpc-server"; +import { avataxConfigSchema } from "./avatax-connection-schema"; +import { PublicAvataxConnectionService } from "./configuration/public-avatax-connection.service"; + +const getInputSchema = z.object({ + id: z.string(), +}); + +const deleteInputSchema = z.object({ + id: z.string(), +}); + +const patchInputSchema = z.object({ + id: z.string(), + value: avataxConfigSchema.deepPartial(), +}); + +const postInputSchema = z.object({ + value: avataxConfigSchema, +}); + +const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) => + next({ + ctx: { + ...ctx, + connectionService: new PublicAvataxConnectionService( + ctx.apiClient, + ctx.appId!, + ctx.saleorApiUrl + ), + }, + }) +); + +export const avataxConnectionRouter = router({ + getById: protectedWithConfigurationService.input(getInputSchema).query(async ({ ctx, input }) => { + const logger = createLogger({ + location: "avataxConnectionRouter.get", + }); + + logger.debug("Route get called"); + + const result = await ctx.connectionService.getById(input.id); + + logger.info(`Avatax configuration with an id: ${result.id} was successfully retrieved`); + + return result; + }), + create: protectedWithConfigurationService + .input(postInputSchema) + .mutation(async ({ ctx, input }) => { + const logger = createLogger({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "avataxConnectionRouter.post", + }); + + logger.debug("Attempting to create configuration"); + + const result = await ctx.connectionService.create(input.value); + + logger.info("Avatax configuration was successfully created"); + + return result; + }), + delete: protectedWithConfigurationService + .input(deleteInputSchema) + .mutation(async ({ ctx, input }) => { + const logger = createLogger({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "avataxConnectionRouter.delete", + }); + + logger.debug("Route delete called"); + + const result = await ctx.connectionService.delete(input.id); + + logger.info(`Avatax configuration with an id: ${input.id} was deleted`); + + return result; + }), + update: protectedWithConfigurationService + .input(patchInputSchema) + .mutation(async ({ ctx, input }) => { + const logger = createLogger({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "avataxConnectionRouter.patch", + }); + + logger.debug("Route patch called"); + + const result = await ctx.connectionService.update(input.id, input.value); + + logger.info(`Avatax configuration with an id: ${input.id} was successfully updated`); + + return result; + }), +}); diff --git a/apps/taxes/src/modules/avatax/avatax-mock-factory.ts b/apps/taxes/src/modules/avatax/avatax-mock-factory.ts index 92329e2..bd7e421 100644 --- a/apps/taxes/src/modules/avatax/avatax-mock-factory.ts +++ b/apps/taxes/src/modules/avatax/avatax-mock-factory.ts @@ -1,5 +1,6 @@ import { avataxMockTransactionFactory } from "./avatax-mock-transaction-factory"; +// todo: refactor to mockGenerator export const avataxMockFactory = { ...avataxMockTransactionFactory, }; diff --git a/apps/taxes/src/modules/avatax/avatax-webhook.service.ts b/apps/taxes/src/modules/avatax/avatax-webhook.service.ts index b66de68..3c9d769 100644 --- a/apps/taxes/src/modules/avatax/avatax-webhook.service.ts +++ b/apps/taxes/src/modules/avatax/avatax-webhook.service.ts @@ -4,10 +4,9 @@ import { TaxBaseFragment, } from "../../../generated/graphql"; import { Logger, createLogger } from "../../lib/logger"; -import { ChannelConfig } from "../channels-configuration/channels-config"; import { ProviderWebhookService } from "../taxes/tax-provider-webhook"; import { AvataxClient } from "./avatax-client"; -import { AvataxConfig, defaultAvataxConfig } from "./avatax-config"; +import { AvataxConfig, defaultAvataxConfig } from "./avatax-connection-schema"; import { AvataxCalculateTaxesAdapter } from "./calculate-taxes/avatax-calculate-taxes-adapter"; import { AvataxOrderCreatedAdapter } from "./order-created/avatax-order-created-adapter"; import { AvataxOrderFulfilledAdapter } from "./order-fulfilled/avatax-order-fulfilled-adapter"; @@ -19,48 +18,35 @@ export class AvataxWebhookService implements ProviderWebhookService { constructor(config: AvataxConfig) { this.logger = createLogger({ - service: "AvataxWebhookService", + location: "AvataxWebhookService", }); const avataxClient = new AvataxClient(config); - this.logger.trace({ client: avataxClient }, "Internal Avatax client created"); - this.config = config; this.client = avataxClient; } - async calculateTaxes(taxBase: TaxBaseFragment, channelConfig: ChannelConfig) { - this.logger.debug({ taxBase, channelConfig }, "calculateTaxes called with:"); + async calculateTaxes(taxBase: TaxBaseFragment) { const adapter = new AvataxCalculateTaxesAdapter(this.config); - const response = await adapter.send({ channelConfig, taxBase }); - - this.logger.debug({ response }, "calculateTaxes response:"); + const response = await adapter.send({ taxBase }); return response; } - async createOrder(order: OrderCreatedSubscriptionFragment, channelConfig: ChannelConfig) { - this.logger.debug({ order, channelConfig }, "createOrder called with:"); - + async createOrder(order: OrderCreatedSubscriptionFragment) { const adapter = new AvataxOrderCreatedAdapter(this.config); - const response = await adapter.send({ channelConfig, order }); - - this.logger.debug({ response }, "createOrder response:"); + const response = await adapter.send({ order }); return response; } - async fulfillOrder(order: OrderFulfilledSubscriptionFragment, channelConfig: ChannelConfig) { - this.logger.debug({ order, channelConfig }, "fulfillOrder called with:"); - + async fulfillOrder(order: OrderFulfilledSubscriptionFragment) { const adapter = new AvataxOrderFulfilledAdapter(this.config); const response = await adapter.send({ order }); - this.logger.debug({ response }, "fulfillOrder response:"); - return response; } } diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-adapter.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-adapter.ts index 32620de..f6cc112 100644 --- a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-adapter.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-adapter.ts @@ -1,39 +1,43 @@ import { TaxBaseFragment } from "../../../../generated/graphql"; import { Logger, createLogger } from "../../../lib/logger"; -import { ChannelConfig } from "../../channels-configuration/channels-config"; import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook"; import { WebhookAdapter } from "../../taxes/tax-webhook-adapter"; import { AvataxClient, CreateTransactionArgs } from "../avatax-client"; -import { AvataxConfig } from "../avatax-config"; +import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxCalculateTaxesPayloadTransformer } from "./avatax-calculate-taxes-payload-transformer"; import { AvataxCalculateTaxesResponseTransformer } from "./avatax-calculate-taxes-response-transformer"; export const SHIPPING_ITEM_CODE = "Shipping"; -export type Payload = { +export type AvataxCalculateTaxesPayload = { taxBase: TaxBaseFragment; - channelConfig: ChannelConfig; - config: AvataxConfig; }; -export type Target = CreateTransactionArgs; -export type Response = CalculateTaxesResponse; +export type AvataxCalculateTaxesTarget = CreateTransactionArgs; +export type AvataxCalculateTaxesResponse = CalculateTaxesResponse; -export class AvataxCalculateTaxesAdapter implements WebhookAdapter { +export class AvataxCalculateTaxesAdapter + implements WebhookAdapter +{ private logger: Logger; constructor(private readonly config: AvataxConfig) { - this.logger = createLogger({ service: "AvataxCalculateTaxesAdapter" }); + this.logger = createLogger({ location: "AvataxCalculateTaxesAdapter" }); } - async send(payload: Pick): Promise { - this.logger.debug({ payload }, "send called with:"); + async send(payload: AvataxCalculateTaxesPayload): Promise { + this.logger.debug({ payload }, "Transforming the following Saleor payload:"); const payloadTransformer = new AvataxCalculateTaxesPayloadTransformer(); - const target = payloadTransformer.transform({ ...payload, config: this.config }); + const target = payloadTransformer.transform({ ...payload, providerConfig: this.config }); + + this.logger.debug( + { transformedPayload: target }, + "Will call Avatax createTransaction with transformed payload:" + ); const client = new AvataxClient(this.config); const response = await client.createTransaction(target); - this.logger.debug({ response }, "Avatax createTransaction response:"); + this.logger.debug({ response }, "Avatax createTransaction responded with:"); const responseTransformer = new AvataxCalculateTaxesResponseTransformer(); const transformedResponse = responseTransformer.transform(response); diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-mock-generator.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-mock-generator.ts index acc8f4b..a025525 100644 --- a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-mock-generator.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-mock-generator.ts @@ -1,6 +1,6 @@ import { TransactionModel } from "avatax/lib/models/TransactionModel"; import { TaxBaseFragment } from "../../../../generated/graphql"; -import { ChannelConfig } from "../../channels-configuration/channels-config"; +import { ChannelConfig } from "../../channel-configuration/channel-config"; import { DocumentStatus } from "avatax/lib/enums/DocumentStatus"; import { DocumentType } from "avatax/lib/enums/DocumentType"; import { AdjustmentReason } from "avatax/lib/enums/AdjustmentReason"; @@ -10,7 +10,9 @@ import { RateType } from "avatax/lib/enums/RateType"; import { ChargedTo } from "avatax/lib/enums/ChargedTo"; import { JurisdictionType } from "avatax/lib/enums/JurisdictionType"; import { BoundaryLevel } from "avatax/lib/enums/BoundaryLevel"; -import { AvataxConfig } from "../avatax-config"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxConfigMockGenerator } from "../avatax-config-mock-generator"; +import { ChannelConfigMockGenerator } from "../../channel-configuration/channel-config-mock-generator"; type TaxBase = TaxBaseFragment; @@ -109,18 +111,6 @@ const defaultTaxBase: TaxBase = { }, }; -const defaultChannelConfig: ChannelConfig = { - providerInstanceId: "b8c29f49-7cae-4762-8458-e9a27eb83081", - enabled: false, - address: { - country: "US", - zip: "92093", - state: "CA", - city: "La Jolla", - street: "9500 Gilman Drive", - }, -}; - const defaultTransactionModel: TransactionModel = { id: 0, code: "aec372bb-f3b3-40fb-9d84-2b46cd67e516", @@ -943,21 +933,9 @@ const defaultTransactionModel: TransactionModel = { ], }; -const defaultAvataxConfig: AvataxConfig = { - companyCode: "DEFAULT", - isAutocommit: false, - isSandbox: true, - name: "Avatax-1", - password: "password", - username: "username", - shippingTaxCode: "FR000000", -}; - const testingScenariosMap = { default: { taxBase: defaultTaxBase, - channelConfig: defaultChannelConfig, - avataxConfig: defaultAvataxConfig, response: defaultTransactionModel, }, }; @@ -972,17 +950,17 @@ export class AvataxCalculateTaxesMockGenerator { ...overrides, }); - generateChannelConfig = (overrides: Partial = {}): ChannelConfig => - structuredClone({ - ...testingScenariosMap[this.scenario].channelConfig, - ...overrides, - }); + generateChannelConfig = (overrides: Partial = {}): ChannelConfig => { + const mockGenerator = new ChannelConfigMockGenerator(); - generateAvataxConfig = (overrides: Partial = {}): AvataxConfig => - structuredClone({ - ...testingScenariosMap[this.scenario].avataxConfig, - ...overrides, - }); + return mockGenerator.generateChannelConfig(overrides); + }; + + generateAvataxConfig = (overrides: Partial = {}): AvataxConfig => { + const mockGenerator = new AvataxConfigMockGenerator(); + + return mockGenerator.generateAvataxConfig(overrides); + }; generateResponse = (overrides: Partial = {}): TransactionModel => structuredClone({ diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.test.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.test.ts index e75741f..f24f7e6 100644 --- a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.test.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.test.ts @@ -13,8 +13,7 @@ describe("AvataxCalculateTaxesPayloadTransformer", () => { const payload = new AvataxCalculateTaxesPayloadTransformer().transform({ taxBase: taxBaseMock, - channelConfig: mockGenerator.generateChannelConfig(), - config: avataxConfigMock, + providerConfig: avataxConfigMock, }); expect(payload.model.discount).toEqual(10); @@ -26,8 +25,7 @@ describe("AvataxCalculateTaxesPayloadTransformer", () => { const payload = new AvataxCalculateTaxesPayloadTransformer().transform({ taxBase: taxBaseMock, - channelConfig: mockGenerator.generateChannelConfig(), - config: avataxConfigMock, + providerConfig: avataxConfigMock, }); expect(payload.model.discount).toEqual(0); diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.ts index d6b2018..18c46b0 100644 --- a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-payload-transformer.ts @@ -1,10 +1,11 @@ +import { DocumentType } from "avatax/lib/enums/DocumentType"; import { LineItemModel } from "avatax/lib/models/LineItemModel"; import { TaxBaseFragment } from "../../../../generated/graphql"; -import { AvataxConfig } from "../avatax-config"; -import { avataxAddressFactory } from "../address-factory"; -import { DocumentType } from "avatax/lib/enums/DocumentType"; -import { SHIPPING_ITEM_CODE, Payload, Target } from "./avatax-calculate-taxes-adapter"; import { discountUtils } from "../../taxes/discount-utils"; +import { avataxAddressFactory } from "../address-factory"; +import { CreateTransactionArgs } from "../avatax-client"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter"; export function mapPayloadLines(taxBase: TaxBaseFragment, config: AvataxConfig): LineItemModel[] { const isDiscounted = taxBase.discounts.length > 0; @@ -35,22 +36,26 @@ export function mapPayloadLines(taxBase: TaxBaseFragment, config: AvataxConfig): } export class AvataxCalculateTaxesPayloadTransformer { - transform(props: Payload): Target { - const { taxBase, channelConfig, config } = props; - + transform({ + taxBase, + providerConfig, + }: { + taxBase: TaxBaseFragment; + providerConfig: AvataxConfig; + }): CreateTransactionArgs { return { model: { type: DocumentType.SalesOrder, customerCode: taxBase.sourceObject.user?.id ?? "", - companyCode: config.companyCode, + companyCode: providerConfig.companyCode, // * commit: If true, the transaction will be committed immediately after it is created. See: https://developer.avalara.com/communications/dev-guide_rest_v2/commit-uncommit - commit: config.isAutocommit, + commit: providerConfig.isAutocommit, addresses: { - shipFrom: avataxAddressFactory.fromChannelAddress(channelConfig.address), + shipFrom: avataxAddressFactory.fromChannelAddress(providerConfig.address), shipTo: avataxAddressFactory.fromSaleorAddress(taxBase.address!), }, currencyCode: taxBase.currency, - lines: mapPayloadLines(taxBase, config), + lines: mapPayloadLines(taxBase, providerConfig), date: new Date(), discount: discountUtils.sumDiscounts( taxBase.discounts.map((discount) => discount.amount.amount) diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-response-lines-transformer.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-response-lines-transformer.ts index 70c2d16..ef88670 100644 --- a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-response-lines-transformer.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-response-lines-transformer.ts @@ -1,10 +1,11 @@ import { TransactionModel } from "avatax/lib/models/TransactionModel"; import { numbers } from "../../taxes/numbers"; import { taxProviderUtils } from "../../taxes/tax-provider-utils"; -import { Response, SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter"; +import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook"; +import { SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter"; export class AvataxCalculateTaxesResponseLinesTransformer { - transform(transaction: TransactionModel): Response["lines"] { + transform(transaction: TransactionModel): CalculateTaxesResponse["lines"] { const productLines = transaction.lines?.filter((line) => line.itemCode !== SHIPPING_ITEM_CODE); return ( diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-response-shipping-transformer.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-response-shipping-transformer.ts index eb0f1aa..4cb259a 100644 --- a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-response-shipping-transformer.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-response-shipping-transformer.ts @@ -1,13 +1,14 @@ import { TransactionModel } from "avatax/lib/models/TransactionModel"; import { numbers } from "../../taxes/numbers"; import { taxProviderUtils } from "../../taxes/tax-provider-utils"; -import { Response, SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter"; +import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook"; +import { SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter"; export class AvataxCalculateTaxesResponseShippingTransformer { transform( transaction: TransactionModel ): Pick< - Response, + CalculateTaxesResponse, "shipping_price_gross_amount" | "shipping_price_net_amount" | "shipping_tax_rate" > { const shippingLine = transaction.lines?.find((line) => line.itemCode === SHIPPING_ITEM_CODE); diff --git a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-response-transformer.ts b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-response-transformer.ts index db1c59c..1079fd2 100644 --- a/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-response-transformer.ts +++ b/apps/taxes/src/modules/avatax/calculate-taxes/avatax-calculate-taxes-response-transformer.ts @@ -1,10 +1,10 @@ import { TransactionModel } from "avatax/lib/models/TransactionModel"; -import { Response } from "./avatax-calculate-taxes-adapter"; +import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook"; import { AvataxCalculateTaxesResponseLinesTransformer } from "./avatax-calculate-taxes-response-lines-transformer"; import { AvataxCalculateTaxesResponseShippingTransformer } from "./avatax-calculate-taxes-response-shipping-transformer"; export class AvataxCalculateTaxesResponseTransformer { - transform(response: TransactionModel): Response { + transform(response: TransactionModel): CalculateTaxesResponse { const shippingTransformer = new AvataxCalculateTaxesResponseShippingTransformer(); const shipping = shippingTransformer.transform(response); diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-connection-repository.ts b/apps/taxes/src/modules/avatax/configuration/avatax-connection-repository.ts new file mode 100644 index 0000000..4c086bf --- /dev/null +++ b/apps/taxes/src/modules/avatax/configuration/avatax-connection-repository.ts @@ -0,0 +1,93 @@ +import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager"; +import { TaxProvidersV1toV2MigrationManager } from "../../../../scripts/migrations/tax-providers-migration-v1-to-v2"; +import { createLogger, Logger } from "../../../lib/logger"; +import { CrudSettingsManager } from "../../crud-settings/crud-settings.service"; +import { + ProviderConnections, + providerConnectionsSchema, +} from "../../provider-connections/provider-connections"; +import { TAX_PROVIDER_KEY } from "../../provider-connections/public-provider-connections.service"; +import { + AvataxConfig, + AvataxConnection, + avataxConnectionSchema, +} from "../avatax-connection-schema"; + +const getSchema = avataxConnectionSchema; + +export class AvataxConnectionRepository { + private crudSettingsManager: CrudSettingsManager; + private logger: Logger; + constructor(private settingsManager: EncryptedMetadataManager, private saleorApiUrl: string) { + this.crudSettingsManager = new CrudSettingsManager( + settingsManager, + saleorApiUrl, + TAX_PROVIDER_KEY + ); + this.logger = createLogger({ + location: "AvataxConnectionRepository", + metadataKey: TAX_PROVIDER_KEY, + }); + } + + private filterAvataxConnections(connections: ProviderConnections): AvataxConnection[] { + return connections.filter( + (connection) => connection.provider === "avatax" + ) as AvataxConnection[]; + } + + async getAll(): Promise { + const { data } = await this.crudSettingsManager.readAll(); + /* + * * migration logic start + * // todo: remove after migration + */ + const migrationManager = new TaxProvidersV1toV2MigrationManager( + this.settingsManager, + this.saleorApiUrl + ); + + const migratedConfig = await migrationManager.migrateIfNeeded(); + + if (migratedConfig) { + this.logger.info("Config migrated", migratedConfig); + return this.filterAvataxConnections(migratedConfig); + } + + this.logger.info("Config is up to date, no need to migrate."); + /* + * * migration logic end + */ + + const connections = providerConnectionsSchema.parse(data); + + const avataxConnections = this.filterAvataxConnections(connections); + + return avataxConnections; + } + + async get(id: string): Promise { + const { data } = await this.crudSettingsManager.read(id); + + const connection = getSchema.parse(data); + + return connection; + } + + async post(config: AvataxConfig): Promise<{ id: string }> { + const result = await this.crudSettingsManager.create({ + provider: "avatax", + config: config, + }); + + return result.data; + } + + async patch(id: string, input: AvataxConfig): Promise { + return this.crudSettingsManager.update(id, input); + } + + async delete(id: string): Promise { + return this.crudSettingsManager.delete(id); + } +} diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-connection.service.ts b/apps/taxes/src/modules/avatax/configuration/avatax-connection.service.ts new file mode 100644 index 0000000..f3cd316 --- /dev/null +++ b/apps/taxes/src/modules/avatax/configuration/avatax-connection.service.ts @@ -0,0 +1,59 @@ +import { Client } from "urql"; +import { Logger, createLogger } from "../../../lib/logger"; +import { AvataxConnectionRepository } from "./avatax-connection-repository"; +import { AvataxConfig, AvataxConnection } from "../avatax-connection-schema"; +import { AvataxValidationService } from "./avatax-validation.service"; +import { DeepPartial } from "@trpc/server"; +import { PatchInputTransformer } from "../../provider-connections/patch-input-transformer"; +import { AuthData } from "@saleor/app-sdk/APL"; +import { createSettingsManager } from "../../app/metadata-manager"; + +export class AvataxConnectionService { + private logger: Logger; + private avataxConnectionRepository: AvataxConnectionRepository; + constructor(client: Client, appId: string, saleorApiUrl: string) { + this.logger = createLogger({ + location: "AvataxConnectionService", + }); + + const settingsManager = createSettingsManager(client, appId); + + this.avataxConnectionRepository = new AvataxConnectionRepository(settingsManager, saleorApiUrl); + } + + getAll(): Promise { + return this.avataxConnectionRepository.getAll(); + } + + getById(id: string): Promise { + return this.avataxConnectionRepository.get(id); + } + + async create(config: AvataxConfig): Promise<{ id: string }> { + const validationService = new AvataxValidationService(); + + await validationService.validate(config); + + return await this.avataxConnectionRepository.post(config); + } + + async update(id: string, nextConfigPartial: DeepPartial): Promise { + const data = await this.getById(id); + // omit the key "id" from the result + const { id: _, ...setting } = data; + const prevConfig = setting.config; + + const validationService = new AvataxValidationService(); + const inputTransformer = new PatchInputTransformer(); + + const input = inputTransformer.transform(nextConfigPartial, prevConfig); + + await validationService.validate(input); + + return this.avataxConnectionRepository.patch(id, input); + } + + async delete(id: string): Promise { + return this.avataxConnectionRepository.delete(id); + } +} diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-validation-error-resolver.test.ts b/apps/taxes/src/modules/avatax/configuration/avatax-validation-error-resolver.test.ts new file mode 100644 index 0000000..6a4509f --- /dev/null +++ b/apps/taxes/src/modules/avatax/configuration/avatax-validation-error-resolver.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { AvataxValidationErrorResolver } from "./avatax-validation-error-resolver"; + +describe("AvataxValidationErrorResolver", () => { + const errorResolver = new AvataxValidationErrorResolver(); + + it("when AuthenticationException error, should return error with specific message", () => { + const result = errorResolver.resolve({ + code: "AuthenticationException", + details: [ + { + message: "message 1", + description: "description 1", + helpLink: "helpLink 1", + code: "code 1", + faultCode: "faultCode 1", + }, + { + message: "message 2", + description: "description 2", + helpLink: "helpLink 2", + code: "code 2", + faultCode: "faultCode 2", + }, + ], + }); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe("Invalid Avatax credentials."); + }); + it("when other Avatax error, should return error with first message", () => { + const result = errorResolver.resolve({ + code: "error", + details: [ + { + message: "message 1", + description: "description 1", + helpLink: "helpLink 1", + code: "code 1", + faultCode: "faultCode 1", + }, + { + message: "message 2", + description: "description 2", + helpLink: "helpLink 2", + code: "code 2", + faultCode: "faultCode 2", + }, + ], + }); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe("message 1"); + }); + it("when unknown error, should return error with generic message", () => { + const result = errorResolver.resolve("error"); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe("Unknown error while validating Avatax configuration."); + }); +}); diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-validation-error-resolver.ts b/apps/taxes/src/modules/avatax/configuration/avatax-validation-error-resolver.ts new file mode 100644 index 0000000..61ce878 --- /dev/null +++ b/apps/taxes/src/modules/avatax/configuration/avatax-validation-error-resolver.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { createLogger, Logger } from "../../../lib/logger"; + +const avataxErrorSchema = z.object({ + code: z.string(), + details: z.array( + z.object({ + description: z.string(), + helpLink: z.string(), + code: z.string(), + message: z.string(), + faultCode: z.string(), + }) + ), +}); + +export class AvataxValidationErrorResolver { + private logger: Logger; + constructor() { + this.logger = createLogger({ + locataion: "AvataxValidationErrorResolver", + }); + } + + resolve(error: unknown): Error { + const parseResult = avataxErrorSchema.safeParse(error); + const isErrorParsed = parseResult.success; + + // Avatax doesn't return a type for their error format, so we need to parse the error + if (isErrorParsed) { + const { code, details } = parseResult.data; + + if (code === "AuthenticationException") { + return new Error("Invalid Avatax credentials."); + } + + return new Error(details[0].message); + } + + if (error instanceof Error) { + return error; + } + + this.logger.error("Unknown error while validating Avatax configuration."); + return new Error("Unknown error while validating Avatax configuration."); + } +} diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-validation-response-resolver.test.ts b/apps/taxes/src/modules/avatax/configuration/avatax-validation-response-resolver.test.ts new file mode 100644 index 0000000..1b935d5 --- /dev/null +++ b/apps/taxes/src/modules/avatax/configuration/avatax-validation-response-resolver.test.ts @@ -0,0 +1,59 @@ +import { AddressResolutionModel } from "avatax/lib/models/AddressResolutionModel"; +import { describe, expect, it } from "vitest"; +import { AvataxValidationResponseResolver } from "./avatax-validation-response-resolver"; +import { ResolutionQuality } from "avatax/lib/enums/ResolutionQuality"; +import { JurisdictionType } from "avatax/lib/enums/JurisdictionType"; + +const mockFailedValidationResponse: AddressResolutionModel = { + address: { + line1: "2000 Main Street", + city: "Irvine", + region: "CA", + country: "US", + postalCode: "92614", + }, + coordinates: { + latitude: 33.684884, + longitude: -117.851321, + }, + resolutionQuality: ResolutionQuality.Intersection, + taxAuthorities: [ + { + avalaraId: "AGAM", + jurisdictionName: "CALIFORNIA", + jurisdictionType: JurisdictionType.State, + signatureCode: "AGAM", + }, + ], + messages: [ + { + summary: "The address is not deliverable.", + details: + "The physical location exists but there are no homes on this street. One reason might be railroad tracks or rivers running alongside this street, as they would prevent construction of homes in this location.", + refersTo: "address", + severity: "Error", + source: "Avalara.AvaTax.Services.Address", + }, + ], +}; + +const mockSuccessfulValidationResponse: AddressResolutionModel = { + ...mockFailedValidationResponse, + messages: [], +}; + +describe("AvataxValidationResponseResolver", () => { + const responseResolver = new AvataxValidationResponseResolver(); + + it("should throw error when messages", () => { + expect(() => responseResolver.resolve(mockFailedValidationResponse)).toThrow(); + }); + + it("should not throw error when no messages", () => { + expect(() => responseResolver.resolve(mockSuccessfulValidationResponse)).not.toThrow(); + }); + + it("should not return anything when no messages", () => { + expect(responseResolver.resolve(mockSuccessfulValidationResponse)).toBeUndefined(); + }); +}); diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-validation-response-resolver.ts b/apps/taxes/src/modules/avatax/configuration/avatax-validation-response-resolver.ts new file mode 100644 index 0000000..5ddb6be --- /dev/null +++ b/apps/taxes/src/modules/avatax/configuration/avatax-validation-response-resolver.ts @@ -0,0 +1,11 @@ +import { AddressResolutionModel } from "avatax/lib/models/AddressResolutionModel"; + +export class AvataxValidationResponseResolver { + resolve(response: AddressResolutionModel) { + if (response.messages && response.messages.length > 0) { + throw new Error( + "The provided address is invalid. Please visit https://developer.avalara.com/avatax/address-validation/ to learn about address formatting." + ); + } + } +} diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-validation.service.ts b/apps/taxes/src/modules/avatax/configuration/avatax-validation.service.ts new file mode 100644 index 0000000..ba9b9e1 --- /dev/null +++ b/apps/taxes/src/modules/avatax/configuration/avatax-validation.service.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; +import { createLogger, Logger } from "../../../lib/logger"; +import { avataxAddressFactory } from "../address-factory"; +import { AvataxClient } from "../avatax-client"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxValidationResponseResolver } from "./avatax-validation-response-resolver"; +import { AvataxValidationErrorResolver } from "./avatax-validation-error-resolver"; + +export class AvataxValidationService { + private logger: Logger; + + constructor() { + this.logger = createLogger({ + location: "AvataxValidationService", + }); + } + + async validate(config: AvataxConfig): Promise { + const avataxClient = new AvataxClient(config); + const address = avataxAddressFactory.fromChannelAddress(config.address); + + try { + const validation = await avataxClient.validateAddress({ address }); + + const responseResolver = new AvataxValidationResponseResolver(); + + responseResolver.resolve(validation); + } catch (error) { + const errorResolver = new AvataxValidationErrorResolver(); + + throw errorResolver.resolve(error); + } + } +} diff --git a/apps/taxes/src/modules/avatax/configuration/public-avatax-connection.service.ts b/apps/taxes/src/modules/avatax/configuration/public-avatax-connection.service.ts new file mode 100644 index 0000000..f635240 --- /dev/null +++ b/apps/taxes/src/modules/avatax/configuration/public-avatax-connection.service.ts @@ -0,0 +1,38 @@ +import { DeepPartial } from "@trpc/server"; +import { Client } from "urql"; +import { AvataxConnectionObfuscator } from "../avatax-connection-obfuscator"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxConnectionService } from "./avatax-connection.service"; + +export class PublicAvataxConnectionService { + private readonly connectionService: AvataxConnectionService; + private readonly obfuscator: AvataxConnectionObfuscator; + constructor(client: Client, appId: string, saleorApiUrl: string) { + this.connectionService = new AvataxConnectionService(client, appId, saleorApiUrl); + this.obfuscator = new AvataxConnectionObfuscator(); + } + + async getAll() { + const connections = await this.connectionService.getAll(); + + return this.obfuscator.obfuscateAvataxConnections(connections); + } + + async getById(id: string) { + const connection = await this.connectionService.getById(id); + + return this.obfuscator.obfuscateAvataxConnection(connection); + } + + async create(config: AvataxConfig) { + return this.connectionService.create(config); + } + + async update(id: string, config: DeepPartial) { + return this.connectionService.update(id, config); + } + + async delete(id: string) { + return this.connectionService.delete(id); + } +} diff --git a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-adapter.ts b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-adapter.ts index 1e25064..64f8ebc 100644 --- a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-adapter.ts +++ b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-adapter.ts @@ -1,38 +1,41 @@ import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql"; -import { ChannelConfig } from "../../channels-configuration/channels-config"; +import { Logger, createLogger } from "../../../lib/logger"; import { CreateOrderResponse } from "../../taxes/tax-provider-webhook"; import { WebhookAdapter } from "../../taxes/tax-webhook-adapter"; -import { AvataxClient, CreateTransactionArgs } from "../avatax-client"; -import { AvataxConfig } from "../avatax-config"; -import { AvataxOrderCreatedResponseTransformer } from "./avatax-order-created-response-transformer"; +import { AvataxClient } from "../avatax-client"; +import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxOrderCreatedPayloadTransformer } from "./avatax-order-created-payload-transformer"; -import { Logger, createLogger } from "../../../lib/logger"; +import { AvataxOrderCreatedResponseTransformer } from "./avatax-order-created-response-transformer"; -export type Payload = { +type AvataxOrderCreatedPayload = { order: OrderCreatedSubscriptionFragment; - channelConfig: ChannelConfig; - config: AvataxConfig; }; -export type Target = CreateTransactionArgs; -type Response = CreateOrderResponse; +type AvataxOrderCreatedResponse = CreateOrderResponse; -export class AvataxOrderCreatedAdapter implements WebhookAdapter { +export class AvataxOrderCreatedAdapter + implements WebhookAdapter +{ private logger: Logger; constructor(private readonly config: AvataxConfig) { - this.logger = createLogger({ service: "AvataxOrderCreatedAdapter" }); + this.logger = createLogger({ location: "AvataxOrderCreatedAdapter" }); } - async send(payload: Pick): Promise { - this.logger.debug({ payload }, "send called with:"); + async send(payload: AvataxOrderCreatedPayload): Promise { + this.logger.debug({ payload }, "Transforming the following Saleor payload:"); - const payloadTransformer = new AvataxOrderCreatedPayloadTransformer(); - const target = payloadTransformer.transform({ ...payload, config: this.config }); + const payloadTransformer = new AvataxOrderCreatedPayloadTransformer(this.config); + const target = payloadTransformer.transform(payload); + + this.logger.debug( + { transformedPayload: target }, + "Will call Avatax createTransaction with transformed payload:" + ); const client = new AvataxClient(this.config); const response = await client.createTransaction(target); - this.logger.debug({ response }, "Avatax createTransaction response:"); + this.logger.debug({ response }, "Avatax createTransaction responded with:"); const responseTransformer = new AvataxOrderCreatedResponseTransformer(); const transformedResponse = responseTransformer.transform(response); diff --git a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-mock-generator.ts b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-mock-generator.ts index 8e3a332..c2e5eee 100644 --- a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-mock-generator.ts +++ b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-mock-generator.ts @@ -1,40 +1,26 @@ import { TransactionModel } from "avatax/lib/models/TransactionModel"; import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql"; -import { ChannelConfig } from "../../channels-configuration/channels-config"; +import { ChannelConfig } from "../../channel-configuration/channel-config"; import { orderCreatedTransactionMock } from "./avatax-order-created-response-transaction-mock"; -import { AvataxConfig } from "../avatax-config"; +import { AvataxConfig } from "../avatax-connection-schema"; import { defaultOrder } from "../../../mocks"; +import { AvataxConfigMockGenerator } from "../avatax-config-mock-generator"; const defaultChannelConfig: ChannelConfig = { - providerInstanceId: "aa5293e5-7f5d-4782-a619-222ead918e50", - enabled: false, - address: { - country: "US", - zip: "95008", - state: "CA", - city: "Campbell", - street: "33 N. First Street", + id: "1", + config: { + providerConnectionId: "aa5293e5-7f5d-4782-a619-222ead918e50", + slug: "default-channel", }, }; const defaultOrderCreatedResponse: TransactionModel = orderCreatedTransactionMock; -const defaultAvataxConfig: AvataxConfig = { - companyCode: "DEFAULT", - isAutocommit: false, - isSandbox: true, - name: "Avatax-1", - password: "password", - username: "username", - shippingTaxCode: "FR000000", -}; - const testingScenariosMap = { default: { order: defaultOrder, channelConfig: defaultChannelConfig, response: defaultOrderCreatedResponse, - avataxConfig: defaultAvataxConfig, }, }; @@ -56,11 +42,11 @@ export class AvataxOrderCreatedMockGenerator { ...overrides, }); - generateAvataxConfig = (overrides: Partial = {}): AvataxConfig => - structuredClone({ - ...testingScenariosMap[this.scenario].avataxConfig, - ...overrides, - }); + generateAvataxConfig = (overrides: Partial = {}): AvataxConfig => { + const mockGenerator = new AvataxConfigMockGenerator(); + + return mockGenerator.generateAvataxConfig(overrides); + }; generateResponse = (overrides: Partial = {}): TransactionModel => structuredClone({ diff --git a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.test.ts b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.test.ts index 4d48522..2cf1c1c 100644 --- a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.test.ts +++ b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.test.ts @@ -22,10 +22,10 @@ const channelConfigMock = mockGenerator.generateChannelConfig(); describe("AvataxOrderCreatedPayloadTransformer", () => { it("returns lines with discounted: true when there are discounts", () => { - const transformer = new AvataxOrderCreatedPayloadTransformer(); + const transformer = new AvataxOrderCreatedPayloadTransformer(avataxConfigMock); const payloadMock = { order: discountedOrderMock, - config: avataxConfigMock, + providerConfig: avataxConfigMock, channelConfig: channelConfigMock, }; @@ -37,10 +37,10 @@ describe("AvataxOrderCreatedPayloadTransformer", () => { expect(check).toBe(true); }); it("returns lines with discounted: false when there are no discounts", () => { - const transformer = new AvataxOrderCreatedPayloadTransformer(); + const transformer = new AvataxOrderCreatedPayloadTransformer(avataxConfigMock); const payloadMock = { order: orderMock, - config: avataxConfigMock, + providerConfig: avataxConfigMock, channelConfig: channelConfigMock, }; diff --git a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.ts b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.ts index 1666697..b70c1b9 100644 --- a/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.ts +++ b/apps/taxes/src/modules/avatax/order-created/avatax-order-created-payload-transformer.ts @@ -1,11 +1,11 @@ import { LineItemModel } from "avatax/lib/models/LineItemModel"; import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql"; import { numbers } from "../../taxes/numbers"; -import { AvataxConfig } from "../avatax-config"; +import { AvataxConfig } from "../avatax-connection-schema"; import { avataxAddressFactory } from "../address-factory"; import { DocumentType } from "avatax/lib/enums/DocumentType"; -import { Payload, Target } from "./avatax-order-created-adapter"; import { discountUtils } from "../../taxes/discount-utils"; +import { CreateTransactionArgs } from "../avatax-client"; const SHIPPING_ITEM_CODE = "Shipping"; @@ -49,24 +49,25 @@ export function mapLines( } export class AvataxOrderCreatedPayloadTransformer { - transform = ({ order, channelConfig, config }: Payload): Target => { + constructor(private readonly providerConfig: AvataxConfig) {} + transform = ({ order }: { order: OrderCreatedSubscriptionFragment }): CreateTransactionArgs => { return { model: { type: DocumentType.SalesInvoice, customerCode: order.user?.id ?? "" /* In Saleor Avatax plugin, the customer code is 0. In Taxes App, we set it to the user id. */, - companyCode: config.companyCode, + companyCode: this.providerConfig.companyCode, // * commit: If true, the transaction will be committed immediately after it is created. See: https://developer.avalara.com/communications/dev-guide_rest_v2/commit-uncommit - commit: config.isAutocommit, + commit: this.providerConfig.isAutocommit, addresses: { - shipFrom: avataxAddressFactory.fromChannelAddress(channelConfig.address), + shipFrom: avataxAddressFactory.fromChannelAddress(this.providerConfig.address), // billing or shipping address? shipTo: avataxAddressFactory.fromSaleorAddress(order.billingAddress!), }, currencyCode: order.total.currency, email: order.user?.email ?? "", - lines: mapLines(order, config), + lines: mapLines(order, this.providerConfig), date: new Date(order.created), discount: discountUtils.sumDiscounts( order.discounts.map((discount) => discount.amount.amount) diff --git a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-adapter.ts b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-adapter.ts index 4841ebf..cae94bf 100644 --- a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-adapter.ts +++ b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-adapter.ts @@ -2,34 +2,40 @@ import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphq import { Logger, createLogger } from "../../../lib/logger"; import { WebhookAdapter } from "../../taxes/tax-webhook-adapter"; import { AvataxClient, CommitTransactionArgs } from "../avatax-client"; -import { AvataxConfig } from "../avatax-config"; +import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxOrderFulfilledPayloadTransformer } from "./avatax-order-fulfilled-payload-transformer"; import { AvataxOrderFulfilledResponseTransformer } from "./avatax-order-fulfilled-response-transformer"; -export type Payload = { +export type AvataxOrderFulfilledPayload = { order: OrderFulfilledSubscriptionFragment; - config: AvataxConfig; }; -export type Target = CommitTransactionArgs; -export type Response = { ok: true }; +export type AvataxOrderFulfilledTarget = CommitTransactionArgs; +export type AvataxOrderFulfilledResponse = { ok: true }; -export class AvataxOrderFulfilledAdapter implements WebhookAdapter { +export class AvataxOrderFulfilledAdapter + implements WebhookAdapter +{ private logger: Logger; constructor(private readonly config: AvataxConfig) { - this.logger = createLogger({ service: "AvataxOrderFulfilledAdapter" }); + this.logger = createLogger({ location: "AvataxOrderFulfilledAdapter" }); } - async send(payload: Pick): Promise { - this.logger.debug({ payload }, "send called with:"); + async send(payload: AvataxOrderFulfilledPayload): Promise { + this.logger.debug({ payload }, "Transforming the following Saleor payload:"); - const payloadTransformer = new AvataxOrderFulfilledPayloadTransformer(); - const target = payloadTransformer.transform({ ...payload, config: this.config }); + const payloadTransformer = new AvataxOrderFulfilledPayloadTransformer(this.config); + const target = payloadTransformer.transform({ ...payload }); + + this.logger.debug( + { transformedPayload: target }, + "Will call Avatax commitTransaction with transformed payload:" + ); const client = new AvataxClient(this.config); const response = await client.commitTransaction(target); - this.logger.debug({ response }, "Avatax commitTransaction response:"); + this.logger.debug({ response }, "Avatax commitTransaction responded with:"); const responseTransformer = new AvataxOrderFulfilledResponseTransformer(); const transformedResponse = responseTransformer.transform(response); diff --git a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.test.ts b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.test.ts index 9d8a675..2a7392d 100644 --- a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.test.ts +++ b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.test.ts @@ -1,8 +1,7 @@ import { DocumentType } from "avatax/lib/enums/DocumentType"; import { describe, expect, it } from "vitest"; import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql"; -import { AvataxConfig } from "../avatax-config"; -import { Payload } from "./avatax-order-fulfilled-adapter"; +import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxOrderFulfilledPayloadTransformer, PROVIDER_ORDER_ID_KEY, @@ -16,9 +15,18 @@ const MOCK_AVATAX_CONFIG: AvataxConfig = { isAutocommit: false, isSandbox: true, name: "Avatax-1", - password: "password", - username: "username", shippingTaxCode: "FR000000", + address: { + country: "US", + zip: "10118", + state: "NY", + city: "New York", + street: "350 5th Avenue", + }, + credentials: { + password: "password", + username: "username", + }, }; const MOCKED_METADATA: OrderFulfilledSubscriptionFragment["privateMetadata"] = [ @@ -121,11 +129,12 @@ describe("getTransactionCodeFromMetadata", () => { }); }); -const transformer = new AvataxOrderFulfilledPayloadTransformer(); +const transformer = new AvataxOrderFulfilledPayloadTransformer(MOCK_AVATAX_CONFIG); -const MOCKED_ORDER_FULFILLED_PAYLOAD: Payload = { +const MOCKED_ORDER_FULFILLED_PAYLOAD: { + order: OrderFulfilledSubscriptionFragment; +} = { order: ORDER_FULFILLED_MOCK, - config: MOCK_AVATAX_CONFIG, }; describe("AvataxOrderFulfilledPayloadTransformer", () => { diff --git a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.ts b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.ts index 1dc2d59..98bed4b 100644 --- a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.ts +++ b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-payload-transformer.ts @@ -1,6 +1,10 @@ -import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql"; import { DocumentType } from "avatax/lib/enums/DocumentType"; -import { Payload, Target } from "./avatax-order-fulfilled-adapter"; +import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { + AvataxOrderFulfilledPayload, + AvataxOrderFulfilledTarget, +} from "./avatax-order-fulfilled-adapter"; // * This is the key that we use to store the provider order id in the Saleor order metadata. @@ -19,12 +23,13 @@ export function getTransactionCodeFromMetadata( } export class AvataxOrderFulfilledPayloadTransformer { - transform({ order, config }: Payload): Target { + constructor(private readonly config: AvataxConfig) {} + transform({ order }: AvataxOrderFulfilledPayload): AvataxOrderFulfilledTarget { const transactionCode = getTransactionCodeFromMetadata(order.privateMetadata); return { transactionCode, - companyCode: config.companyCode ?? "", + companyCode: this.config.companyCode ?? "", documentType: DocumentType.SalesInvoice, model: { commit: true, diff --git a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-response-transformer.ts b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-response-transformer.ts index 801422b..2d3a5bb 100644 --- a/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-response-transformer.ts +++ b/apps/taxes/src/modules/avatax/order-fulfilled/avatax-order-fulfilled-response-transformer.ts @@ -1,8 +1,8 @@ import { TransactionModel } from "avatax/lib/models/TransactionModel"; -import { Response } from "./avatax-order-fulfilled-adapter"; +import { AvataxOrderFulfilledResponse } from "./avatax-order-fulfilled-adapter"; export class AvataxOrderFulfilledResponseTransformer { - transform(response: TransactionModel): Response { + transform(response: TransactionModel): AvataxOrderFulfilledResponse { return { ok: true }; } } diff --git a/apps/taxes/src/modules/avatax/ui/avatax-configuration-form.tsx b/apps/taxes/src/modules/avatax/ui/avatax-configuration-form.tsx index 26fe017..f3f21c5 100644 --- a/apps/taxes/src/modules/avatax/ui/avatax-configuration-form.tsx +++ b/apps/taxes/src/modules/avatax/ui/avatax-configuration-form.tsx @@ -1,318 +1,223 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { - FormHelperText, - Grid, - InputLabel, - Switch, - TextField, - TextFieldProps, -} from "@material-ui/core"; -import { Delete, Save } from "@material-ui/icons"; -import { Button, makeStyles } from "@saleor/macaw-ui"; +import { TextLink } from "@saleor/apps-ui"; +import { Box, Button, Divider, Text } from "@saleor/macaw-ui/next"; +import { Input } from "@saleor/react-hook-form-macaw"; import React from "react"; -import { Controller, useForm } from "react-hook-form"; -import { z } from "zod"; -import { useInstanceId } from "../../taxes/tax-context"; -import { trpcClient } from "../../trpc/trpc-client"; -import { AppLink } from "../../ui/app-link"; -import { avataxConfigSchema } from "../avatax-config"; -import { useDashboardNotification } from "@saleor/apps-shared"; +import { useForm } from "react-hook-form"; +import { AppCard } from "../../ui/app-card"; +import { AppToggle } from "../../ui/app-toggle"; +import { CountrySelect } from "../../ui/country-select"; +import { ProviderLabel } from "../../ui/provider-label"; +import { AvataxConfig, avataxConfigSchema, defaultAvataxConfig } from "../avatax-connection-schema"; -const useStyles = makeStyles((theme) => ({ - reverseRow: { - display: "flex", - flexDirection: "row-reverse", - gap: theme.spacing(1), - }, -})); - -const schema = avataxConfigSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - companyCode: "", - isAutocommit: false, - isSandbox: false, - password: "", - username: "", - name: "", - shippingTaxCode: "", +const HelperText = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); }; -export const AvataxConfigurationForm = () => { - const { notifySuccess, notifyError } = useDashboardNotification(); - const [isWarningDialogOpen, setIsWarningDialogOpen] = React.useState(false); - const styles = useStyles(); - const { handleSubmit, reset, control, formState } = useForm({ - resolver: zodResolver(schema), - defaultValues, - }); - const { instanceId, setInstanceId } = useInstanceId(); - const { refetch: refetchChannelConfigurationData } = - trpcClient.channelsConfiguration.fetch.useQuery(undefined, { - onError(error) { - notifyError("Error", error.message); - }, - }); - const { refetch: refetchProvidersConfigurationData } = - trpcClient.providersConfiguration.getAll.useQuery(); - const { data: instance } = trpcClient.avataxConfiguration.get.useQuery( - { id: instanceId ?? "" }, - { - enabled: !!instanceId, - onError(error) { - notifyError("Error", error.message); - }, - } - ); +type AvataxConfigurationFormProps = { + onSubmit: (data: AvataxConfig) => void; + defaultValues: AvataxConfig; + isLoading: boolean; + cancelButton: React.ReactNode; +}; - const resetInstanceId = () => { - setInstanceId(null); - }; +export const AvataxConfigurationForm = (props: AvataxConfigurationFormProps) => { + const { handleSubmit, control, formState, reset } = useForm({ + defaultValues: defaultAvataxConfig, + resolver: zodResolver(avataxConfigSchema), + }); React.useEffect(() => { - if (instance) { - const { config } = instance; + reset(props.defaultValues); + }, [props.defaultValues, reset]); - reset(config); - } else { - reset(defaultValues); - } - }, [instance, reset]); - - const { mutate: createMutation, isLoading: isCreateLoading } = - trpcClient.avataxConfiguration.post.useMutation({ - onSuccess({ id }) { - setInstanceId(id); - refetchProvidersConfigurationData(); - notifySuccess("Success", "Saved app configuration"); - }, - onError(error) { - notifyError("Error", error.message); - }, - }); - - const { mutate: updateMutation, isLoading: isUpdateLoading } = - trpcClient.avataxConfiguration.patch.useMutation({ - onSuccess() { - refetchProvidersConfigurationData(); - notifySuccess("Success", "Updated Avalara configuration"); - }, - onError(error) { - notifyError("Error", error.message); - }, - }); - - const { mutate: deleteMutation } = trpcClient.avataxConfiguration.delete.useMutation({ - onSuccess() { - resetInstanceId(); - refetchProvidersConfigurationData(); - refetchChannelConfigurationData(); - notifySuccess("Success", "Removed Avatax instance"); + const submitHandler = React.useCallback( + (data: AvataxConfig) => { + props.onSubmit(data); }, - onError(error) { - notifyError("Error", error.message); - }, - }); - - const textFieldProps: TextFieldProps = { - fullWidth: true, - }; - - const onSubmit = (value: FormValues) => { - if (instanceId) { - updateMutation({ - id: instanceId, - value, - }); - } else { - createMutation({ - value, - }); - } - }; - - const closeWarningDialog = () => { - setIsWarningDialogOpen(false); - }; - - const openWarningDialog = () => { - setIsWarningDialogOpen(true); - }; - - const deleteProvider = () => { - closeWarningDialog(); - if (instanceId) { - deleteMutation({ id: instanceId }); - } - }; - - const isLoading = isCreateLoading || isUpdateLoading; + [props] + ); return ( - <> -
- - - ( - - )} - /> - {formState.errors.name && ( - {formState.errors.name.message} - )} - - - - Sandbox - + + + + + + + Unique identifier for your provider. + + + Credentials + + + +
+ ( - field.onChange(e.target.checked)} - /> - )} + name="credentials.username" + required + label="Username *" + helperText={formState.errors.credentials?.username?.message} /> - - - Toggling between{" "} - - Production and Sandbox - {" "} - environments.{" "} - - - - - Autocommit - + You can obtain it in the API Keys section of Settings → License{" "} + in your Avalara Dashboard. + +
+
+ ( - field.onChange(e.target.checked)} - /> - )} + name="credentials.password" + type="password" + required + label="Password *" + helperText={formState.errors.credentials?.password?.message} /> - - - If enabled, the order will be automatically{" "} - - committed to Avalara. - {" "} - - - - + You can obtain it in the API Keys section of Settings → License{" "} + in your Avalara Dashboard. + +
+ +
+ + + When not provided, the default company will be used.{" "} + + Read more + {" "} + about company codes. + +
+
+ + ( - - )} - /> - {formState.errors.username && ( - {formState.errors.username.message} - )} -
- - ( - - )} + label="Use sandbox mode" + helperText={ + + Toggling between{" "} + + Production and Sandbox + {" "} + environment. + + } + name="isSandbox" /> - {formState.errors.password && ( - {formState.errors.password.message} - )} - - - ( - - )} + label="Autocommit" + helperText={ + + If enabled, the order will be automatically{" "} + + commited to Avalara. + {" "} + + } + name="isAutocommit" /> - - {"When not provided, the default company will be used. "} - - Read more - {" "} - about company codes. - - {formState.errors.companyCode && ( - {formState.errors.companyCode.message} - )} - - - + + + + Address + + + + + + + + + + + Tax codes + + +
+ ( - - )} + label="Shipping tax code" + helperText={formState.errors.shippingTaxCode?.message} /> - - {"Tax code that for the shipping line sent to Avatax. "} - + + Tax code that for the shipping line sent to Avatax.{" "} + Must match Avatax tax codes format. - - - {formState.errors.shippingTaxCode && ( - {formState.errors.shippingTaxCode.message} - )} - - -
-
-
+ + + + + {props.cancelButton} + + - {instanceId && ( - - )} -
+
- {/* */} - + ); }; diff --git a/apps/taxes/src/modules/avatax/ui/avatax-configuration.tsx b/apps/taxes/src/modules/avatax/ui/avatax-configuration.tsx deleted file mode 100644 index f4467dc..0000000 --- a/apps/taxes/src/modules/avatax/ui/avatax-configuration.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { AvataxConfigurationForm } from "./avatax-configuration-form"; - -export const AvataxConfiguration = () => { - return ( -
-

Avatax configuration

- -
- ); -}; diff --git a/apps/taxes/src/modules/avatax/ui/avatax-instructions.tsx b/apps/taxes/src/modules/avatax/ui/avatax-instructions.tsx new file mode 100644 index 0000000..f29bda0 --- /dev/null +++ b/apps/taxes/src/modules/avatax/ui/avatax-instructions.tsx @@ -0,0 +1,52 @@ +import { TextLink } from "@saleor/apps-ui"; +import { Box, Text } from "@saleor/macaw-ui/next"; +import { Section } from "../../ui/app-section"; + +export const AvataxInstructions = () => { + return ( + + The form consists of two sections: Credentials and Address. +
+
+ Credentials will fail if: + +
  • + - The username or password are incorrect. +
  • +
  • + + - The combination of username and password do not match "sandbox mode" + setting. + +
  • +
    +
    +
    + Address will fail if: +
    + +
  • + + - The address does not match{" "} + + the desired format + + . + +
  • +
    +
    +
    + If the configuration fails, please visit the{" "} + + Avatax documentation + + . + + } + /> + ); +}; diff --git a/apps/taxes/src/modules/avatax/ui/create-avatax-configuration.tsx b/apps/taxes/src/modules/avatax/ui/create-avatax-configuration.tsx new file mode 100644 index 0000000..9ac2d43 --- /dev/null +++ b/apps/taxes/src/modules/avatax/ui/create-avatax-configuration.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { AvataxConfigurationForm } from "./avatax-configuration-form"; +import { AvataxConfig, defaultAvataxConfig } from "../avatax-connection-schema"; +import { trpcClient } from "../../trpc/trpc-client"; +import { useDashboardNotification } from "@saleor/apps-shared"; +import { useRouter } from "next/router"; +import { Button } from "@saleor/macaw-ui/next"; + +export const CreateAvataxConfiguration = () => { + const router = useRouter(); + const { notifySuccess, notifyError } = useDashboardNotification(); + + const { refetch: refetchProvidersConfigurationData } = + trpcClient.providersConfiguration.getAll.useQuery(); + + const { mutate: createMutation, isLoading: isCreateLoading } = + trpcClient.avataxConnection.create.useMutation({ + async onSuccess() { + notifySuccess("Success", "Provider created"); + await refetchProvidersConfigurationData(); + router.push("/configuration"); + }, + onError(error) { + notifyError("Error", error.message); + }, + }); + + const submitHandler = React.useCallback( + (data: AvataxConfig) => { + createMutation({ value: data }); + }, + [createMutation] + ); + + return ( + router.push("/configuration")} variant="tertiary"> + Cancel + + } + /> + ); +}; diff --git a/apps/taxes/src/modules/avatax/ui/edit-avatax-configuration.tsx b/apps/taxes/src/modules/avatax/ui/edit-avatax-configuration.tsx new file mode 100644 index 0000000..18a388d --- /dev/null +++ b/apps/taxes/src/modules/avatax/ui/edit-avatax-configuration.tsx @@ -0,0 +1,107 @@ +import { useDashboardNotification } from "@saleor/apps-shared"; +import { Box, Button, Text } from "@saleor/macaw-ui/next"; +import { useRouter } from "next/router"; +import React from "react"; +import { z } from "zod"; +import { Obfuscator } from "../../../lib/obfuscator"; +import { trpcClient } from "../../trpc/trpc-client"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxConfigurationForm } from "./avatax-configuration-form"; +import { AvataxConnectionObfuscator } from "../avatax-connection-obfuscator"; + +const avataxObfuscator = new AvataxConnectionObfuscator(); + +export const EditAvataxConfiguration = () => { + const router = useRouter(); + const { id } = router.query; + + const configurationId = z.string().parse(id ?? ""); + + const { refetch: refetchProvidersConfigurationData } = + trpcClient.providersConfiguration.getAll.useQuery(); + + const { notifySuccess, notifyError } = useDashboardNotification(); + const { mutate: patchMutation, isLoading: isPatchLoading } = + trpcClient.avataxConnection.update.useMutation({ + onSuccess() { + notifySuccess("Success", "Updated Avatax configuration"); + refetchProvidersConfigurationData(); + }, + onError(error) { + notifyError("Error", error.message); + }, + }); + + const { mutate: deleteMutation, isLoading: isDeleteLoading } = + trpcClient.avataxConnection.delete.useMutation({ + onSuccess() { + notifySuccess("Success", "Deleted Avatax configuration"); + refetchProvidersConfigurationData(); + router.push("/configuration"); + }, + onError(error) { + notifyError("Error", error.message); + }, + }); + + const { + data, + isLoading: isGetLoading, + isError: isGetError, + } = trpcClient.avataxConnection.getById.useQuery( + { + id: configurationId, + }, + { + enabled: !!configurationId, + } + ); + + const submitHandler = React.useCallback( + (data: AvataxConfig) => { + patchMutation({ + value: avataxObfuscator.filterOutObfuscated(data), + id: configurationId, + }); + }, + [configurationId, patchMutation] + ); + + const deleteHandler = () => { + /* + * // todo: add support for window.confirm to AppBridge or wait on Dialog component in Macaw + * if (window.confirm("Are you sure you want to delete the provider?")) { + */ + deleteMutation({ id: configurationId }); + // } + }; + + if (isGetLoading) { + // todo: replace with skeleton once its available in Macaw + return ( + + Loading... + + ); + } + + if (isGetError) { + return ( + + Error while fetching the provider data. + + ); + } + return ( + + Delete provider + + } + /> + ); +}; diff --git a/apps/taxes/src/modules/channel-configuration/channel-config-mock-generator.ts b/apps/taxes/src/modules/channel-configuration/channel-config-mock-generator.ts new file mode 100644 index 0000000..0f913e0 --- /dev/null +++ b/apps/taxes/src/modules/channel-configuration/channel-config-mock-generator.ts @@ -0,0 +1,25 @@ +import { ChannelConfig } from "./channel-config"; + +const defaultChannelConfig: ChannelConfig = { + id: "1", + config: { + providerConnectionId: "aa5293e5-7f5d-4782-a619-222ead918e50", + slug: "default-channel", + }, +}; + +const testingScenariosMap = { + default: defaultChannelConfig, +}; + +type TestingScenario = keyof typeof testingScenariosMap; + +export class ChannelConfigMockGenerator { + constructor(private scenario: TestingScenario = "default") {} + + generateChannelConfig = (overrides: Partial = {}): ChannelConfig => + structuredClone({ + ...testingScenariosMap[this.scenario], + ...overrides, + }); +} diff --git a/apps/taxes/src/modules/channel-configuration/channel-config.ts b/apps/taxes/src/modules/channel-configuration/channel-config.ts new file mode 100644 index 0000000..2048587 --- /dev/null +++ b/apps/taxes/src/modules/channel-configuration/channel-config.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const channelConfigPropertiesSchema = z.object({ + providerConnectionId: z.string().or(z.null()), + slug: z.string(), +}); + +export type ChannelConfigProperties = z.infer; + +export const channelConfigSchema = z.object({ + id: z.string(), + config: channelConfigPropertiesSchema, +}); + +export type ChannelConfig = z.infer; + +export const channelsSchema = z.array(channelConfigSchema); +export type ChannelsConfig = z.infer; diff --git a/apps/taxes/src/modules/channel-configuration/channel-configuration-merger.test.ts b/apps/taxes/src/modules/channel-configuration/channel-configuration-merger.test.ts new file mode 100644 index 0000000..53f8537 --- /dev/null +++ b/apps/taxes/src/modules/channel-configuration/channel-configuration-merger.test.ts @@ -0,0 +1,48 @@ +import { expect, it, describe } from "vitest"; +import { ChannelConfigMockGenerator } from "./channel-config-mock-generator"; +import { ChannelFetcherMockGenerator } from "./channel-fetcher-mock-generator"; +import { ChannelConfigurationMerger } from "./channel-configuration-merger"; + +const channelMockGenerator = new ChannelFetcherMockGenerator(); +const channelConfigMockGenerator = new ChannelConfigMockGenerator(); +const configurationMerger = new ChannelConfigurationMerger(); + +describe("ChannelConfigurationMerger", () => { + it("should return config with providerConnectionId when match", () => { + const channels = [channelMockGenerator.generateChannel()]; + const channelConfig = channelConfigMockGenerator.generateChannelConfig(); + + const result = configurationMerger.merge(channels, [channelConfig]); + + expect(result).toEqual([ + { + id: "1", + config: { + providerConnectionId: "aa5293e5-7f5d-4782-a619-222ead918e50", + slug: "default-channel", + }, + }, + ]); + }); + it("should return config with providerConnectionId = null when no match", () => { + const channels = [channelMockGenerator.generateChannel()]; + const channelConfig = channelConfigMockGenerator.generateChannelConfig({ + config: { + providerConnectionId: "1234", + slug: "different-channel", + }, + }); + + const result = configurationMerger.merge(channels, [channelConfig]); + + expect(result).toEqual([ + { + id: "1", + config: { + providerConnectionId: null, + slug: "default-channel", + }, + }, + ]); + }); +}); diff --git a/apps/taxes/src/modules/channel-configuration/channel-configuration-merger.ts b/apps/taxes/src/modules/channel-configuration/channel-configuration-merger.ts new file mode 100644 index 0000000..7c2eb20 --- /dev/null +++ b/apps/taxes/src/modules/channel-configuration/channel-configuration-merger.ts @@ -0,0 +1,18 @@ +import { ChannelFragment } from "../../../generated/graphql"; +import { ChannelsConfig } from "./channel-config"; + +export class ChannelConfigurationMerger { + merge(channels: ChannelFragment[], channelsConfig: ChannelsConfig): ChannelsConfig { + return channels.map((channel) => { + const channelConfig = channelsConfig.find((c) => c.config.slug === channel.slug); + + return { + id: channel.id, + config: { + providerConnectionId: channelConfig?.config.providerConnectionId ?? null, + slug: channel.slug, + }, + }; + }); + } +} diff --git a/apps/taxes/src/modules/channel-configuration/channel-configuration-settings.ts b/apps/taxes/src/modules/channel-configuration/channel-configuration-settings.ts new file mode 100644 index 0000000..a02c6da --- /dev/null +++ b/apps/taxes/src/modules/channel-configuration/channel-configuration-settings.ts @@ -0,0 +1,29 @@ +import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager"; +import { Logger, createLogger } from "../../lib/logger"; +import { CrudSettingsManager } from "../crud-settings/crud-settings.service"; +import { ChannelConfigProperties, channelsSchema } from "./channel-config"; + +export class ChannelConfigurationSettings { + private crudSettingsManager: CrudSettingsManager; + private logger: Logger; + constructor(private settingsManager: EncryptedMetadataManager, saleorApiUrl: string) { + this.crudSettingsManager = new CrudSettingsManager( + settingsManager, + saleorApiUrl, + "channel-configuration" + ); + this.logger = createLogger({ + location: "ChannelConfigurationSettings", + }); + } + + async getAll() { + const { data } = await this.crudSettingsManager.readAll(); + + return channelsSchema.parse(data); + } + + async upsert(id: string, data: ChannelConfigProperties) { + await this.crudSettingsManager.upsert(id, { config: data }); + } +} diff --git a/apps/taxes/src/modules/channel-configuration/channel-configuration.router.ts b/apps/taxes/src/modules/channel-configuration/channel-configuration.router.ts new file mode 100644 index 0000000..c99ddc2 --- /dev/null +++ b/apps/taxes/src/modules/channel-configuration/channel-configuration.router.ts @@ -0,0 +1,46 @@ +import { createLogger } from "../../lib/logger"; +import { protectedClientProcedure } from "../trpc/protected-client-procedure"; +import { router } from "../trpc/trpc-server"; +import { channelConfigSchema } from "./channel-config"; +import { ChannelConfigurationService } from "./channel-configuration.service"; + +const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) => + next({ + ctx: { + ...ctx, + connectionService: new ChannelConfigurationService( + ctx.apiClient, + ctx.appId!, + ctx.saleorApiUrl + ), + }, + }) +); + +export const channelsConfigurationRouter = router({ + fetch: protectedWithConfigurationService.query(async ({ ctx, input }) => { + const logger = createLogger({ + location: "channelsConfigurationRouter.fetch", + }); + + const channelConfiguration = ctx.connectionService; + + logger.info("Returning channel configuration"); + + return channelConfiguration.getAll(); + }), + upsert: protectedWithConfigurationService + .input(channelConfigSchema) + .mutation(async ({ ctx, input }) => { + const logger = createLogger({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "channelsConfigurationRouter.upsert", + }); + + const configurationService = ctx.connectionService; + + await configurationService.update(input.id, input.config); + + logger.info("Channel configuration updated"); + }), +}); diff --git a/apps/taxes/src/modules/channel-configuration/channel-configuration.service.ts b/apps/taxes/src/modules/channel-configuration/channel-configuration.service.ts new file mode 100644 index 0000000..21e566c --- /dev/null +++ b/apps/taxes/src/modules/channel-configuration/channel-configuration.service.ts @@ -0,0 +1,55 @@ +import { Client } from "urql"; +import { ChannelConfigProperties } from "./channel-config"; +import { ChannelConfigurationSettings } from "./channel-configuration-settings"; +import { ChannelsFetcher } from "./channel-fetcher"; +import { ChannelConfigurationMerger } from "./channel-configuration-merger"; +import { TaxChannelsV1toV2MigrationManager } from "../../../scripts/migrations/tax-channels-migration-v1-to-v2"; +import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager"; +import { Logger, createLogger } from "../../lib/logger"; +import { createSettingsManager } from "../app/metadata-manager"; + +export class ChannelConfigurationService { + private configurationService: ChannelConfigurationSettings; + private logger: Logger; + private settingsManager: EncryptedMetadataManager; + constructor(private client: Client, private appId: string, private saleorApiUrl: string) { + const settingsManager = createSettingsManager(client, appId); + + this.settingsManager = settingsManager; + + this.logger = createLogger({ + location: "ChannelConfigurationService", + }); + + this.configurationService = new ChannelConfigurationSettings(settingsManager, saleorApiUrl); + } + + async getAll() { + const channelsFetcher = new ChannelsFetcher(this.client); + + const migrationManager = new TaxChannelsV1toV2MigrationManager( + this.settingsManager, + this.saleorApiUrl + ); + + const migratedConfig = await migrationManager.migrateIfNeeded(); + + if (migratedConfig) { + this.logger.info("Config migrated", migratedConfig); + return migratedConfig; + } + + this.logger.info("Config is up to date, no need to migrate."); + const channels = await channelsFetcher.fetchChannels(); + + const channelConfiguration = await this.configurationService.getAll(); + + const configurationMerger = new ChannelConfigurationMerger(); + + return configurationMerger.merge(channels, channelConfiguration); + } + + async update(id: string, data: ChannelConfigProperties) { + await this.configurationService.upsert(id, data); + } +} diff --git a/apps/taxes/src/modules/channel-configuration/channel-fetcher-mock-generator.ts b/apps/taxes/src/modules/channel-configuration/channel-fetcher-mock-generator.ts new file mode 100644 index 0000000..014d64e --- /dev/null +++ b/apps/taxes/src/modules/channel-configuration/channel-fetcher-mock-generator.ts @@ -0,0 +1,23 @@ +import { ChannelFragment } from "../../../generated/graphql"; + +const defaultChannel: ChannelFragment = { + id: "1", + name: "Default Channel", + slug: "default-channel", +}; + +const testingScenariosMap = { + default: defaultChannel, +}; + +type TestingScenario = keyof typeof testingScenariosMap; + +export class ChannelFetcherMockGenerator { + constructor(private scenario: TestingScenario = "default") {} + + generateChannel = (overrides: Partial = {}): ChannelFragment => + structuredClone({ + ...testingScenariosMap[this.scenario], + ...overrides, + }); +} diff --git a/apps/taxes/src/modules/channels/channels-fetcher.ts b/apps/taxes/src/modules/channel-configuration/channel-fetcher.ts similarity index 100% rename from apps/taxes/src/modules/channels/channels-fetcher.ts rename to apps/taxes/src/modules/channel-configuration/channel-fetcher.ts diff --git a/apps/taxes/src/modules/channel-configuration/ui/channel-list.tsx b/apps/taxes/src/modules/channel-configuration/ui/channel-list.tsx new file mode 100644 index 0000000..c0f217c --- /dev/null +++ b/apps/taxes/src/modules/channel-configuration/ui/channel-list.tsx @@ -0,0 +1,61 @@ +import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; +import { Box, Button, Text } from "@saleor/macaw-ui/next"; +import { trpcClient } from "../../trpc/trpc-client"; +import { AppCard } from "../../ui/app-card"; +import { ChannelTable } from "./channel-table"; + +const NoChannelConfigured = () => { + const appBridge = useAppBridge(); + + const redirectToTaxes = () => { + appBridge.appBridge?.dispatch(actions.Redirect({ to: "/taxes/channels" })); + }; + + return ( + + No channels configured yet + + + ); +}; + +const Skeleton = () => { + // todo: replace with skeleton + return ( + + Loading... + + ); +}; + +export const ChannelList = () => { + const { data = [], isFetching, isFetched } = trpcClient.channelsConfiguration.fetch.useQuery(); + + const isAnyChannelConfigured = data.length > 0; + const isResult = isFetched && isAnyChannelConfigured; + const isEmpty = isFetched && !isAnyChannelConfigured; + + return ( + + {isFetching ? ( + + ) : ( + <> + {isEmpty && } + {isResult && ( + + + + )} + + )} + + ); +}; diff --git a/apps/taxes/src/modules/channel-configuration/ui/channel-section.tsx b/apps/taxes/src/modules/channel-configuration/ui/channel-section.tsx new file mode 100644 index 0000000..9db53bd --- /dev/null +++ b/apps/taxes/src/modules/channel-configuration/ui/channel-section.tsx @@ -0,0 +1,49 @@ +import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; +import { Text } from "@saleor/macaw-ui/next"; +import { Section } from "../../ui/app-section"; +import { ChannelList } from "./channel-list"; + +const Intro = () => { + const appBridge = useAppBridge(); + + const redirectToTaxes = () => { + appBridge.appBridge?.dispatch(actions.Redirect({ to: "/taxes/channels" })); + }; + + return ( + + This table displays all the channels configured to use the tax app as the tax calculation + method. +
    +
    + You can change the tax configuration method for each channel in the{" "} + + Configuration → Taxes + {" "} + view. + + } + /> + ); +}; + +export const ChannelSection = () => { + return ( + <> + + + + ); +}; diff --git a/apps/taxes/src/modules/channel-configuration/ui/channel-table.tsx b/apps/taxes/src/modules/channel-configuration/ui/channel-table.tsx new file mode 100644 index 0000000..702ae08 --- /dev/null +++ b/apps/taxes/src/modules/channel-configuration/ui/channel-table.tsx @@ -0,0 +1,76 @@ +import { Select } from "@saleor/macaw-ui/next"; +import React from "react"; +import { trpcClient } from "../../trpc/trpc-client"; +import { Table } from "../../ui/table"; +import { ChannelConfig } from "../channel-config"; +import { useDashboardNotification } from "@saleor/apps-shared"; + +const SelectProvider = (channelConfig: ChannelConfig) => { + const { + config: { providerConnectionId = "", slug }, + id, + } = channelConfig; + const [value, setValue] = React.useState(providerConnectionId); + const { notifySuccess, notifyError } = useDashboardNotification(); + + const { mutate: upsertMutation } = trpcClient.channelsConfiguration.upsert.useMutation({ + onSuccess() { + notifySuccess("Success", "Updated channel configuration"); + }, + onError(error) { + notifyError("Error", error.message); + }, + }); + + const { data: providerConfigurations = [] } = trpcClient.providersConfiguration.getAll.useQuery(); + + const changeValue = (nextproviderConnectionId: string) => { + setValue(nextproviderConnectionId); + upsertMutation({ + id, + config: { + providerConnectionId: nextproviderConnectionId, + slug, + }, + }); + }; + + return ( + - {providerInstances.map(({ config, id, provider }) => ( - -
    - {config.name} - -
    -
    - ))} - - )} - /> - -
    - - - Ship from address - - The taxes will be calculated based on the address. - - - - - } - /> - - - } - /> - - - ( - - )} - /> - - - ( - - )} - /> - - - ( - - )} - /> - - - - -
    -
    -
    - {" "} -
    - - - ); -}; diff --git a/apps/taxes/src/modules/channels/ui/channel-tax-provider.tsx b/apps/taxes/src/modules/channels/ui/channel-tax-provider.tsx deleted file mode 100644 index 8005f58..0000000 --- a/apps/taxes/src/modules/channels/ui/channel-tax-provider.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { Grid, Typography } from "@material-ui/core"; -import { Warning } from "@material-ui/icons"; -import { Skeleton } from "@material-ui/lab"; -import { Button, makeStyles } from "@saleor/macaw-ui"; -import { PropsWithChildren } from "react"; -import { useAppRedirect } from "../../../lib/app/redirect"; -import { ProviderIcon } from "../../providers-configuration/ui/provider-icon"; -import { providerConfig, TaxProviderName } from "../../taxes/provider-config"; -import { useActiveTab, useChannelSlug, useInstanceId } from "../../taxes/tax-context"; -import { trpcClient } from "../../trpc/trpc-client"; -import { AppLink } from "../../ui/app-link"; -import { AppPaper } from "../../ui/app-paper"; -import { ChannelTaxProviderForm } from "./channel-tax-provider-form"; - -const useStyles = makeStyles((theme) => ({ - centerWithGap: { - display: "flex", - alignItems: "center", - gap: theme.spacing(1), - }, -})); - -const NoDataPlaceholder = ({ - title, - children, -}: PropsWithChildren<{ - title: string; -}>) => { - const styles = useStyles(); - - return ( - -
    - - - {title} - - - -
    - {children} -
    -
    - ); -}; - -const NoChannelPlaceholder = () => { - const { redirect } = useAppRedirect(); - - return ( - - - For a channel to appear on this list, you need to configure it on the{" "} - Tax Configuration page. - -
    - - By default, each channel will use flat rates as the tax calculation method. If you - want a channel to calculate taxes using the Tax App, you need to change the tax calculation - method to Use tax app. - -
    - -
    - ); -}; - -const NoProviderPlaceholder = () => { - const styles = useStyles(); - const { setActiveTab } = useActiveTab(); - const { setInstanceId } = useInstanceId(); - - return ( - - - You need to set up at least one tax provider before you can configure a channel. - -
    - - We currently support the following tax providers: -
      - {Object.entries(providerConfig).map(([provider, { label }]) => ( - - - {label} - - - - ))} -
    -
    - -
    - ); -}; - -const ChannelTaxProviderSkeleton = () => { - return ( - - - - - - - - -
    - - - - - - - - - - - - - - - - - - -
    -
    - ); -}; - -export const ChannelTaxProvider = () => { - const { channelSlug } = useChannelSlug(); - const channels = trpcClient.channels.fetch.useQuery(undefined, {}); - const providers = trpcClient.providersConfiguration.getAll.useQuery(); - - if (channels?.isFetching || providers?.isFetching) { - return ; - } - - if (!channelSlug) { - return ; - } - - if (!providers?.data?.length) { - return ; - } - - return ( - - - - ); -}; diff --git a/apps/taxes/src/modules/channels/ui/channels-list.tsx b/apps/taxes/src/modules/channels/ui/channels-list.tsx deleted file mode 100644 index a02c5b3..0000000 --- a/apps/taxes/src/modules/channels/ui/channels-list.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { makeStyles } from "@saleor/macaw-ui"; -import { - OffsettedList, - OffsettedListBody, - OffsettedListHeader, - OffsettedListItem, - OffsettedListItemCell, -} from "@saleor/macaw-ui"; -import clsx from "clsx"; -import { ChannelFragment } from "../../../../generated/graphql"; - -const useStyles = makeStyles((theme) => { - return { - headerItem: { - height: "auto !important", - display: "grid", - gridTemplateColumns: "1fr", - }, - listItem: { - cursor: "pointer", - height: "auto !important", - display: "grid", - gridTemplateColumns: "1fr", - }, - listItemActive: { - border: `2px solid ${theme.palette.primary.main}`, - }, - cellSlug: { - fontFamily: "monospace", - opacity: 0.8, - }, - }; -}); - -type Props = { - channels: ChannelFragment[]; - activeChannelSlug: string; - onChannelClick(channelSlug: string): void; -}; - -export const ChannelsList = ({ channels, activeChannelSlug, onChannelClick }: Props) => { - const styles = useStyles(); - - return ( - - - - Channel name - - - - {channels.map((c) => { - return ( - { - onChannelClick(c.slug); - }} - > - {c.name} - - ); - })} - - - ); -}; diff --git a/apps/taxes/src/modules/channels/ui/channels.tsx b/apps/taxes/src/modules/channels/ui/channels.tsx deleted file mode 100644 index 24850da..0000000 --- a/apps/taxes/src/modules/channels/ui/channels.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Grid } from "@material-ui/core"; -import { Skeleton } from "@material-ui/lab"; -import { useChannelSlug } from "../../taxes/tax-context"; -import { trpcClient } from "../../trpc/trpc-client"; -import { AppPaper } from "../../ui/app-paper"; -import { ChannelsList } from "./channels-list"; - -const ChannelsSkeleton = () => { - return ( - - - - - - - - - - - ); -}; - -export const Channels = () => { - const { channelSlug, setChannelSlug } = useChannelSlug(); - - const channels = trpcClient.channels.fetch.useQuery(undefined, { - onSuccess: (result) => { - if (result?.[0]) { - setChannelSlug(result?.[0].slug); - } - }, - }); - - if (channels?.isFetching) { - return ; - } - - if (channels.error) { - return
    Error. No channel available
    ; - } - - if (channels.data?.length === 0) { - // empty space for grid - return
    ; - } - - return ( - - setChannelSlug(nextSlug)} - /> - - ); -}; diff --git a/apps/taxes/src/modules/crud-settings/crud-settings.service.test.ts b/apps/taxes/src/modules/crud-settings/crud-settings.service.test.ts new file mode 100644 index 0000000..90f199d --- /dev/null +++ b/apps/taxes/src/modules/crud-settings/crud-settings.service.test.ts @@ -0,0 +1,160 @@ +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CrudSettingsManager } from "./crud-settings.service"; + +describe("CrudSettingsService", () => { + let mockSettingsManager: SettingsManager = { + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }; + + let service: CrudSettingsManager; + + beforeEach(() => { + mockSettingsManager = { + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }; + + service = new CrudSettingsManager(mockSettingsManager, "apiUrl", "metadataKey"); + }); + + describe("readAll", () => { + it("returns an empty array if nothing found", async () => { + const result = await service.readAll(); + + expect(result).toEqual({ data: [] }); + }); + it("returns an array of settings if found", async () => { + const encryptedValue = JSON.stringify([{ id: "id", key: "value" }]); + + vi.mocked(mockSettingsManager.get).mockImplementation(async () => { + return encryptedValue; + }); + + const result = await service.readAll(); + + expect(result).toEqual({ data: [{ id: "id", key: "value" }] }); + }); + it("throws an error if the settings are invalid", async () => { + const encryptedValue = JSON.stringify({}); + + vi.mocked(mockSettingsManager.get).mockImplementation(async () => { + return encryptedValue; + }); + + await expect(service.readAll()).rejects.toThrowError("Error while validating metadata"); + }); + }); + + describe("read", () => { + it("throws an error if the settings are invalid", async () => { + const encryptedValue = JSON.stringify({}); + + vi.mocked(mockSettingsManager.get).mockImplementation(async () => { + return encryptedValue; + }); + + await expect(service.read("id")).rejects.toThrowError("Error while validating metadata"); + }); + + it("throws an error if the item is not found", async () => { + const encryptedValue = JSON.stringify([{ id: "id", key: "value" }]); + + vi.mocked(mockSettingsManager.get).mockImplementation(async () => { + return encryptedValue; + }); + + await expect(service.read("id2")).rejects.toThrowError("Item not found"); + }); + + it("returns the item if found", async () => { + const encryptedValue = JSON.stringify([{ id: "id", key: "value" }]); + + vi.mocked(mockSettingsManager.get).mockImplementation(async () => { + return encryptedValue; + }); + + const result = await service.read("id"); + + expect(result).toEqual({ data: { id: "id", key: "value" } }); + }); + }); + + describe("create", () => { + it("creates a new item", async () => { + vi.mocked(mockSettingsManager.set).mockImplementation(async () => {}); + + const result = await service.create({ key: "value" }); + + expect(result).toEqual({ data: { id: expect.any(String) } }); + }); + }); + + describe("delete", () => { + it("deletes an item", async () => { + vi.mocked(mockSettingsManager.get).mockImplementation(async () => { + return JSON.stringify([{ id: "id", key: "value" }]); + }); + + await service.delete("id"); + + expect(mockSettingsManager.set).toHaveBeenCalledWith({ + domain: "apiUrl", + key: "metadataKey", + value: JSON.stringify([]), + }); + }); + }); + + describe("upsert", () => { + it("creates a new item if it doesn't exist", async () => { + vi.mocked(mockSettingsManager.get).mockImplementation(async () => { + return JSON.stringify([{ id: "id", key: "value" }]); + }); + + await service.upsert("id2", { key: "value2" }); + + expect(mockSettingsManager.set).toHaveBeenCalledWith({ + domain: "apiUrl", + key: "metadataKey", + value: JSON.stringify([ + { id: "id", key: "value" }, + { id: "id2", key: "value2" }, + ]), + }); + }); + + it("updates an existing item", async () => { + vi.mocked(mockSettingsManager.get).mockImplementation(async () => { + return JSON.stringify([{ id: "id", key: "value" }]); + }); + + await service.upsert("id", { key: "value2" }); + + expect(mockSettingsManager.set).toHaveBeenCalledWith({ + domain: "apiUrl", + key: "metadataKey", + value: JSON.stringify([{ id: "id", key: "value2" }]), + }); + }); + }); + + describe("update", () => { + it("partially updates an existing item", async () => { + vi.mocked(mockSettingsManager.get).mockImplementation(async () => { + return JSON.stringify([{ id: "id", data: [], config: { foo: "bar" } }]); + }); + + await service.update("id", { config: { foo: "baz" } }); + + expect(mockSettingsManager.set).toHaveBeenCalledWith({ + domain: "apiUrl", + key: "metadataKey", + value: JSON.stringify([{ id: "id", data: [], config: { foo: "baz" } }]), + }); + }); + }); +}); diff --git a/apps/taxes/src/modules/crud-settings/crud-settings.service.ts b/apps/taxes/src/modules/crud-settings/crud-settings.service.ts index 32410fd..aab60b5 100644 --- a/apps/taxes/src/modules/crud-settings/crud-settings.service.ts +++ b/apps/taxes/src/modules/crud-settings/crud-settings.service.ts @@ -11,20 +11,27 @@ export class CrudSettingsManager { private logger: Logger; constructor( + /* + * // todo: invoke createSettingsManager in constructor + * // todo: constructor should accept schema that should be used to validate data + * Currently, CrudSettingsManager has a big limitation of not validating the inputs in any way. + * We rely on the classes that implement CrudSettingsManager to provide the data in the correct format, + * but when you are doing that you must be aware of certain choices CrudSettingsManager makes for you + * (like creating an "id" field, or how it updates the data). + * So if you make a mistake in data transformations in your class, you will not get any errors. + */ private metadataManager: SettingsManager, private saleorApiUrl: string, private metadataKey: string ) { this.metadataKey = metadataKey; - this.logger = createLogger({ service: "CrudSettingsManager", metadataKey }); + this.logger = createLogger({ location: "CrudSettingsManager", metadataKey }); } async readAll() { - this.logger.trace(".readAll called"); const result = await this.metadataManager.get(this.metadataKey, this.saleorApiUrl); if (!result) { - this.logger.trace("No metadata found"); return { data: [] }; } @@ -42,7 +49,6 @@ export class CrudSettingsManager { } async read(id: string) { - this.logger.trace(".read called"); const result = await this.readAll(); const { data: settings } = result; @@ -59,8 +65,6 @@ export class CrudSettingsManager { } async create(data: any) { - this.logger.trace(data, ".create called with:"); - const settings = await this.readAll(); const prevData = settings.data; @@ -79,8 +83,6 @@ export class CrudSettingsManager { } async delete(id: string) { - this.logger.trace(`.delete called with: ${id}`); - const settings = await this.readAll(); const prevData = settings.data; const nextData = prevData.filter((item) => item.id !== id); @@ -92,19 +94,43 @@ export class CrudSettingsManager { }); } - async update(id: string, data: any) { - this.logger.trace(data, `.update called with: ${id}`); - const { data: settings } = await this.readAll(); - const nextData = settings.map((item) => { + async update(id: string, input: any) { + const { data: currentSettings } = await this.readAll(); + const nextSettings = currentSettings.map((item) => { if (item.id === id) { - return { id, ...data }; + const { id, ...rest } = item; + + return { id, ...rest, ...input }; } return item; }); + this.logger.debug({ nextSettings }, "nextSettings"); + await this.metadataManager.set({ key: this.metadataKey, - value: JSON.stringify(nextData), + value: JSON.stringify(nextSettings), + domain: this.saleorApiUrl, + }); + } + + async upsert(id: string, input: any) { + const { data: currentSettings } = await this.readAll(); + // update if its there + const nextSettings = currentSettings.map((item) => { + if (item.id === id) { + return { id, ...input }; + } + return item; + }); + + if (!currentSettings.find((item) => item.id === id)) { + nextSettings.push({ id, ...input }); + } + + await this.metadataManager.set({ + key: this.metadataKey, + value: JSON.stringify(nextSettings), domain: this.saleorApiUrl, }); } diff --git a/apps/taxes/src/modules/provider-connections/patch-input-transformer.ts b/apps/taxes/src/modules/provider-connections/patch-input-transformer.ts new file mode 100644 index 0000000..0b407ce --- /dev/null +++ b/apps/taxes/src/modules/provider-connections/patch-input-transformer.ts @@ -0,0 +1,10 @@ +import { DeepPartial } from "@trpc/server"; + +export class PatchInputTransformer { + transform( + nextConfigPartial: DeepPartial, + prevConfig: TObject + ): TObject { + return Object.assign(prevConfig, nextConfigPartial); + } +} diff --git a/apps/taxes/src/modules/provider-connections/provider-connections.router.ts b/apps/taxes/src/modules/provider-connections/provider-connections.router.ts new file mode 100644 index 0000000..01bc2b3 --- /dev/null +++ b/apps/taxes/src/modules/provider-connections/provider-connections.router.ts @@ -0,0 +1,22 @@ +import { createLogger } from "../../lib/logger"; +import { protectedClientProcedure } from "../trpc/protected-client-procedure"; +import { router } from "../trpc/trpc-server"; +import { PublicProviderConnectionsService } from "./public-provider-connections.service"; + +export const providerConnectionsRouter = router({ + getAll: protectedClientProcedure.query(async ({ ctx }) => { + const logger = createLogger({ + location: "providerConnectionsRouter.getAll", + }); + + const items = await new PublicProviderConnectionsService( + ctx.apiClient, + ctx.appId!, + ctx.saleorApiUrl + ).getAll(); + + logger.info("Returning tax providers configuration"); + + return items; + }), +}); diff --git a/apps/taxes/src/modules/provider-connections/provider-connections.ts b/apps/taxes/src/modules/provider-connections/provider-connections.ts new file mode 100644 index 0000000..740937a --- /dev/null +++ b/apps/taxes/src/modules/provider-connections/provider-connections.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; +import { avataxConnectionSchema } from "../avatax/avatax-connection-schema"; +import { taxJarConnection } from "../taxjar/taxjar-connection-schema"; + +export const providerConnectionSchema = taxJarConnection.or(avataxConnectionSchema); + +export const providerConnectionsSchema = z.array(providerConnectionSchema); + +export type ProviderConnections = z.infer; +export type ProviderConnection = z.infer; +export type ProviderName = ProviderConnection["provider"]; diff --git a/apps/taxes/src/modules/provider-connections/public-provider-connections.service.ts b/apps/taxes/src/modules/provider-connections/public-provider-connections.service.ts new file mode 100644 index 0000000..ac5441c --- /dev/null +++ b/apps/taxes/src/modules/provider-connections/public-provider-connections.service.ts @@ -0,0 +1,27 @@ +import { Client } from "urql"; +import { createLogger, Logger } from "../../lib/logger"; +import { PublicAvataxConnectionService } from "../avatax/configuration/public-avatax-connection.service"; +import { PublicTaxJarConnectionService } from "../taxjar/configuration/public-taxjar-connection.service"; + +export const TAX_PROVIDER_KEY = "tax-providers-v2"; + +export class PublicProviderConnectionsService { + private avataxConnectionService: PublicAvataxConnectionService; + private taxJarConnectionService: PublicTaxJarConnectionService; + private logger: Logger; + constructor(client: Client, appId: string, saleorApiUrl: string) { + this.avataxConnectionService = new PublicAvataxConnectionService(client, appId, saleorApiUrl); + this.taxJarConnectionService = new PublicTaxJarConnectionService(client, appId, saleorApiUrl); + this.logger = createLogger({ + location: "PublicProviderConnectionsService", + metadataKey: TAX_PROVIDER_KEY, + }); + } + + async getAll() { + const taxJar = await this.taxJarConnectionService.getAll(); + const avatax = await this.avataxConnectionService.getAll(); + + return [...taxJar, ...avatax]; + } +} diff --git a/apps/taxes/src/modules/provider-connections/ui/providers-list.tsx b/apps/taxes/src/modules/provider-connections/ui/providers-list.tsx new file mode 100644 index 0000000..bca22f2 --- /dev/null +++ b/apps/taxes/src/modules/provider-connections/ui/providers-list.tsx @@ -0,0 +1,66 @@ +import { Box, Button, Text } from "@saleor/macaw-ui/next"; +import { useRouter } from "next/router"; +import { trpcClient } from "../../trpc/trpc-client"; +import { AppCard } from "../../ui/app-card"; +import { ProvidersTable } from "../../ui/providers-table"; + +const AddProvider = () => { + const router = useRouter(); + + return ( + + No providers configured yet + + + ); +}; + +const Skeleton = () => { + // todo: replace with skeleton + return ( + + Loading... + + ); +}; + +export const ProvidersList = () => { + const { data, isFetching, isFetched } = trpcClient.providersConfiguration.getAll.useQuery(); + const router = useRouter(); + + const isProvider = (data?.length ?? 0) > 0; + const isResult = isFetched && isProvider; + const isNoResult = isFetched && !isProvider; + + return ( + + {isFetching ? ( + + ) : ( + <> + {isNoResult && } + {isResult && ( + + + + + + + )} + + )} + + ); +}; diff --git a/apps/taxes/src/modules/provider-connections/ui/providers-section.tsx b/apps/taxes/src/modules/provider-connections/ui/providers-section.tsx new file mode 100644 index 0000000..a0c9824 --- /dev/null +++ b/apps/taxes/src/modules/provider-connections/ui/providers-section.tsx @@ -0,0 +1,36 @@ +import { TextLink } from "@saleor/apps-ui"; +import { Section } from "../../ui/app-section"; +import { ProvidersList } from "./providers-list"; + +const Intro = () => { + return ( + + Saleor offers two ways of calculating taxes: flat or dynamic rates. +
    +
    + Taxes App leverages the dynamic rates by delegating the tax calculation to third-party + services. +
    +
    + You can read more about how Saleor deals with taxes in{" "} + + our documentation + + . + + } + /> + ); +}; + +export const ProvidersSection = () => { + return ( + <> + + + + ); +}; diff --git a/apps/taxes/src/modules/providers-configuration/providers-config-input-schema.ts b/apps/taxes/src/modules/providers-configuration/providers-config-input-schema.ts deleted file mode 100644 index f130a03..0000000 --- a/apps/taxes/src/modules/providers-configuration/providers-config-input-schema.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from "zod"; -import { providerSchema } from "./providers-config"; - -export const updateProviderInstanceInputSchema = z.object({ - id: z.string(), - provider: providerSchema, -}); - -export const deleteProviderInstanceInputSchema = z.object({ - id: z.string(), -}); - -export const createProviderInstanceInputSchema = z.object({ - provider: providerSchema, -}); diff --git a/apps/taxes/src/modules/providers-configuration/providers-config.ts b/apps/taxes/src/modules/providers-configuration/providers-config.ts deleted file mode 100644 index 1fbdd71..0000000 --- a/apps/taxes/src/modules/providers-configuration/providers-config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from "zod"; -import { avataxInstanceConfigSchema } from "../avatax/avatax-config"; -import { taxJarInstanceConfigSchema } from "../taxjar/taxjar-config"; - -export const providerSchema = taxJarInstanceConfigSchema.or(avataxInstanceConfigSchema); -export const providersSchema = z.array(providerSchema); - -export type ProvidersConfig = z.infer; -export type ProviderConfig = z.infer; diff --git a/apps/taxes/src/modules/providers-configuration/providers-configuration.router.ts b/apps/taxes/src/modules/providers-configuration/providers-configuration.router.ts deleted file mode 100644 index be47d9d..0000000 --- a/apps/taxes/src/modules/providers-configuration/providers-configuration.router.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createLogger } from "../../lib/logger"; -import { protectedClientProcedure } from "../trpc/protected-client-procedure"; -import { router } from "../trpc/trpc-server"; -import { PublicTaxProvidersConfigurationService } from "./public-providers-configuration-service"; - -export const providersConfigurationRouter = router({ - getAll: protectedClientProcedure.query(async ({ ctx }) => { - const logger = createLogger({ - saleorApiUrl: ctx.saleorApiUrl, - procedure: "providersConfigurationRouter.getAll", - }); - - logger.debug("providersConfigurationRouter.fetch called"); - - return new PublicTaxProvidersConfigurationService(ctx.apiClient, ctx.saleorApiUrl).getAll(); - }), -}); diff --git a/apps/taxes/src/modules/providers-configuration/public-providers-configuration-service.ts b/apps/taxes/src/modules/providers-configuration/public-providers-configuration-service.ts deleted file mode 100644 index 7cc1bda..0000000 --- a/apps/taxes/src/modules/providers-configuration/public-providers-configuration-service.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Client } from "urql"; -import { createLogger, Logger } from "../../lib/logger"; -import { obfuscateAvataxInstances } from "../avatax/avatax-config"; -import { AvataxConfigurationService } from "../avatax/avatax-configuration.service"; -import { obfuscateTaxJarInstances } from "../taxjar/taxjar-config"; -import { TaxJarConfigurationService } from "../taxjar/taxjar-configuration.service"; - -export const TAX_PROVIDER_KEY = "tax-providers"; - -export class PublicTaxProvidersConfigurationService { - private avataxConfigurationService: AvataxConfigurationService; - private taxJarConfigurationService: TaxJarConfigurationService; - private logger: Logger; - constructor(client: Client, saleorApiUrl: string) { - this.avataxConfigurationService = new AvataxConfigurationService(client, saleorApiUrl); - this.taxJarConfigurationService = new TaxJarConfigurationService(client, saleorApiUrl); - this.logger = createLogger({ - service: "PublicTaxProvidersConfigurationService", - metadataKey: TAX_PROVIDER_KEY, - }); - } - - async getAll() { - this.logger.debug(".getAll called"); - const taxJar = await this.taxJarConfigurationService.getAll(); - const avatax = await this.avataxConfigurationService.getAll(); - - return [...obfuscateTaxJarInstances(taxJar), ...obfuscateAvataxInstances(avatax)]; - } -} diff --git a/apps/taxes/src/modules/providers-configuration/ui/configuration.tsx b/apps/taxes/src/modules/providers-configuration/ui/configuration.tsx deleted file mode 100644 index 6b7f721..0000000 --- a/apps/taxes/src/modules/providers-configuration/ui/configuration.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { FormControlLabel, Grid, Radio, RadioGroup, Typography } from "@material-ui/core"; -import { makeStyles } from "@saleor/macaw-ui"; -import React from "react"; -import { AvataxConfiguration } from "../../avatax/ui/avatax-configuration"; -import { providerConfig, TaxProviderName } from "../../taxes/provider-config"; -import { TaxJarConfiguration } from "../../taxjar/ui/taxjar-configuration"; -import { useInstanceId } from "../../taxes/tax-context"; -import { trpcClient } from "../../trpc/trpc-client"; -import { AppPaper } from "../../ui/app-paper"; -import { ProviderIcon } from "./provider-icon"; - -const providersConfigurationComponent: Record = { - taxjar: TaxJarConfiguration, - avatax: AvataxConfiguration, -}; - -const useStyles = makeStyles((theme) => ({ - radioLabel: { - width: "100%", - padding: theme.spacing(1), - border: `1px solid ${theme.palette.divider}`, - "&:hover": { - backgroundColor: - theme.palette.type === "dark" ? theme.palette.primary.dark : theme.palette.grey[50], - }, - }, - gridItem: { - display: "flex", - justifyContent: "center", - }, - radioLabelActive: { - backgroundColor: - theme.palette.type === "dark" ? theme.palette.primary.dark : theme.palette.grey[50], - }, - iconWithLabel: { - display: "flex", - alignItems: "center", - flexDirection: "column", - gap: theme.spacing(1), - }, -})); - -export const Configuration = () => { - const [provider, setProvider] = React.useState("taxjar"); - const { instanceId } = useInstanceId(); - const { data: providersConfigurationData } = trpcClient.providersConfiguration.getAll.useQuery(); - const styles = useStyles(); - - React.useEffect(() => { - const instance = providersConfigurationData?.find((instance) => instance.id === instanceId); - - setProvider(instance?.provider ?? "taxjar"); - }, [instanceId, providersConfigurationData]); - - const SelectedConfigurationForm = React.useMemo( - () => (provider ? providersConfigurationComponent[provider] : () => null), - [provider] - ); - - return ( - - {!instanceId && ( - - -
    - - Please select one of the providers: - -
    -
    - - setProvider(e.target.value as TaxProviderName)} - > - - {Object.entries(providerConfig).map(([name, config]) => ( - - } - label={ -
    - - {config.label} -
    - } - labelPlacement="top" - aria-label={config.label} - /> -
    - ))} -
    -
    -
    -
    - )} - -
    - ); -}; diff --git a/apps/taxes/src/modules/providers-configuration/ui/delete-provider-dialog.tsx b/apps/taxes/src/modules/providers-configuration/ui/delete-provider-dialog.tsx deleted file mode 100644 index 247c70b..0000000 --- a/apps/taxes/src/modules/providers-configuration/ui/delete-provider-dialog.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { - Dialog, - DialogTitle, - DialogContent, - DialogContentText, - DialogActions, -} from "@material-ui/core"; -import { Button, makeStyles } from "@saleor/macaw-ui"; - -type DeleteProviderDialogProps = { - isOpen: boolean; - onClose: () => void; - onCancel: () => void; - onConfirm: () => void; -}; - -const useStyles = makeStyles((theme) => ({ - actions: { - display: "flex", - gap: theme.spacing(1), - }, -})); - -export const DeleteProviderDialog = (p: DeleteProviderDialogProps) => { - const styles = useStyles(); - - return ( - - Delete provider instance? - - - Are you sure you want to delete this provider instance? This action cannot be undone. - - - -
    - - -
    -
    -
    - ); -}; diff --git a/apps/taxes/src/modules/providers-configuration/ui/provider-icon.tsx b/apps/taxes/src/modules/providers-configuration/ui/provider-icon.tsx deleted file mode 100644 index d62d127..0000000 --- a/apps/taxes/src/modules/providers-configuration/ui/provider-icon.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import Image, { ImageProps } from "next/image"; -import { providerConfig, TaxProviderName } from "../../taxes/provider-config"; - -type Size = "small" | "medium" | "large" | "xlarge"; - -const sizes: Record = { - small: 16, - medium: 24, - large: 32, - xlarge: 48, -}; - -type ProviderIconProps = { - provider: TaxProviderName; - size?: Size; -} & Omit; - -export const ProviderIcon = ({ provider, size = "medium", ...props }: ProviderIconProps) => { - const { icon, label } = providerConfig[provider]; - const matchedSize = sizes[size]; - - return ( - {`${label} - ); -}; diff --git a/apps/taxes/src/modules/providers-configuration/ui/providers-instances-list.tsx b/apps/taxes/src/modules/providers-configuration/ui/providers-instances-list.tsx deleted file mode 100644 index f53dc4e..0000000 --- a/apps/taxes/src/modules/providers-configuration/ui/providers-instances-list.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { - makeStyles, - OffsettedList, - OffsettedListBody, - OffsettedListHeader, - OffsettedListItem, - OffsettedListItemCell, -} from "@saleor/macaw-ui"; -import clsx from "clsx"; -import { useInstanceId } from "../../taxes/tax-context"; -import { trpcClient } from "../../trpc/trpc-client"; -import { AppPaper } from "../../ui/app-paper"; -import { ProviderIcon } from "./provider-icon"; - -const useStyles = makeStyles((theme) => { - return { - headerItem: { - height: "auto !important", - display: "grid", - gridTemplateColumns: "1fr", - }, - listItem: { - cursor: "pointer", - height: "auto !important", - display: "grid", - gridTemplateColumns: "1fr", - }, - listItemActive: { - border: `2px solid ${theme.palette.primary.main}`, - }, - cell: { - display: "flex", - alignItems: "center", - gap: theme.spacing(1), - }, - }; -}); - -export const TaxProvidersInstancesList = () => { - const styles = useStyles(); - const { instanceId, setInstanceId } = useInstanceId(); - const { data: providersConfigurationData } = trpcClient.providersConfiguration.getAll.useQuery(); - const instances = providersConfigurationData ?? []; - - return ( - - - - - Tax provider list - - - - {instances.map((instance) => ( - setInstanceId(instance.id)} - className={clsx(styles.listItem, instance.id === instanceId && styles.listItemActive)} - key={instance.id} - > - - {instance.config.name} - - - - ))} - - - - ); -}; diff --git a/apps/taxes/src/modules/providers-configuration/ui/providers-instances.tsx b/apps/taxes/src/modules/providers-configuration/ui/providers-instances.tsx deleted file mode 100644 index 5918e38..0000000 --- a/apps/taxes/src/modules/providers-configuration/ui/providers-instances.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Grid } from "@material-ui/core"; -import { Add } from "@material-ui/icons"; -import { Skeleton } from "@material-ui/lab"; -import { Button, makeStyles } from "@saleor/macaw-ui"; -import { useInstanceId } from "../../taxes/tax-context"; -import { trpcClient } from "../../trpc/trpc-client"; -import { AppPaper } from "../../ui/app-paper"; -import { TaxProvidersInstancesList } from "./providers-instances-list"; - -const useStyles = makeStyles((theme) => { - return { - button: { - padding: theme.spacing(1, 2), - justifyContent: "flex-start", - }, - }; -}); - -const ProvidersSkeleton = () => { - return ( - - - - - - - - - - - - - - ); -}; - -export const ProvidersInstances = () => { - const styles = useStyles(); - const providers = trpcClient.providersConfiguration.getAll.useQuery(); - const { setInstanceId } = useInstanceId(); - - if (providers?.isFetching) { - return ; - } - - if (providers.error) { - return
    Error. No provider available
    ; - } - - const isAnyProvider = providers.data?.length !== 0; - - if (!isAnyProvider) { - return
    ; - } - - return ( - - - - - - - - - ); -}; diff --git a/apps/taxes/src/modules/taxes/active-tax-provider.test.ts b/apps/taxes/src/modules/taxes/active-connection.test.ts similarity index 51% rename from apps/taxes/src/modules/taxes/active-tax-provider.test.ts rename to apps/taxes/src/modules/taxes/active-connection.test.ts index ba2d5de..e59490f 100644 --- a/apps/taxes/src/modules/taxes/active-tax-provider.test.ts +++ b/apps/taxes/src/modules/taxes/active-connection.test.ts @@ -1,9 +1,9 @@ import { encrypt } from "@saleor/app-sdk/settings-manager"; import { describe, expect, it, vi } from "vitest"; import { MetadataItem } from "../../../generated/graphql"; -import { ChannelsConfig } from "../channels-configuration/channels-config"; -import { ProvidersConfig } from "../providers-configuration/providers-config"; -import { getActiveTaxProvider } from "./active-tax-provider"; +import { ChannelsConfig } from "../channel-configuration/channel-config"; +import { ProviderConnections } from "../provider-connections/provider-connections"; +import { getActiveConnection } from "./active-connection"; const mockedInvalidMetadata: MetadataItem[] = [ { @@ -15,7 +15,7 @@ const mockedInvalidMetadata: MetadataItem[] = [ ]; const mockedSecretKey = "test_secret_key"; -const mockedProviders: ProvidersConfig = [ +const mockedProviders: ProviderConnections = [ { provider: "avatax", id: "1", @@ -24,9 +24,18 @@ const mockedProviders: ProvidersConfig = [ isAutocommit: false, isSandbox: true, name: "avatax-1", - password: "avatax-password", - username: "avatax-username", shippingTaxCode: "FR000000", + credentials: { + password: "avatax-password", + username: "avatax-username", + }, + address: { + city: "New York", + country: "US", + state: "NY", + street: "123 Main St", + zip: "10001", + }, }, }, { @@ -34,43 +43,44 @@ const mockedProviders: ProvidersConfig = [ id: "2", config: { name: "taxjar-1", - apiKey: "taxjar-api-key", isSandbox: true, + credentials: { + apiKey: "taxjar-api-key", + }, + address: { + city: "New York", + country: "US", + state: "NY", + street: "123 Main St", + zip: "10001", + }, }, }, ]; const mockedEncryptedProviders = encrypt(JSON.stringify(mockedProviders), mockedSecretKey); -const mockedChannelsWithInvalidProviderInstanceId: ChannelsConfig = { - "default-channel": { - address: { - city: "New York", - country: "US", - state: "NY", - street: "123 Main St", - zip: "10001", +const mockedChannelsWithInvalidproviderConnectionId: ChannelsConfig = [ + { + id: "1", + config: { + providerConnectionId: "3", + slug: "default-channel", }, - enabled: true, - providerInstanceId: "3", }, -}; +]; -const mockedValidChannels: ChannelsConfig = { - "default-channel": { - address: { - city: "New York", - country: "US", - state: "NY", - street: "123 Main St", - zip: "10001", +const mockedValidChannels: ChannelsConfig = [ + { + id: "1", + config: { + providerConnectionId: "1", + slug: "default-channel", }, - enabled: true, - providerInstanceId: "1", }, -}; +]; const mockedInvalidEncryptedChannels = encrypt( - JSON.stringify(mockedChannelsWithInvalidProviderInstanceId), + JSON.stringify(mockedChannelsWithInvalidproviderConnectionId), mockedSecretKey ); @@ -78,22 +88,22 @@ const mockedValidEncryptedChannels = encrypt(JSON.stringify(mockedValidChannels) vi.stubEnv("SECRET_KEY", mockedSecretKey); -describe("getActiveTaxProvider", () => { +describe("getActiveConnection", () => { it("throws error when channel slug is missing", () => { - expect(() => getActiveTaxProvider("", mockedInvalidMetadata)).toThrow( - "Channel slug is missing" + expect(() => getActiveConnection("", mockedInvalidMetadata)).toThrow( + "Channel slug was not found in the webhook payload" ); }); it("throws error when there are no metadata items", () => { - expect(() => getActiveTaxProvider("default-channel", [])).toThrow( - "App encryptedMetadata is missing" + expect(() => getActiveConnection("default-channel", [])).toThrow( + "App encryptedMetadata was not found in the webhook payload" ); }); - it("throws error when no providerInstanceId was found", () => { + it("throws error when no providerConnectionId was found", () => { expect(() => - getActiveTaxProvider("default-channel", [ + getActiveConnection("default-channel", [ { key: "providers", value: mockedEncryptedProviders, @@ -103,12 +113,12 @@ describe("getActiveTaxProvider", () => { value: mockedInvalidEncryptedChannels, }, ]) - ).toThrow("Channel (default-channel) providerInstanceId does not match any providers"); + ).toThrow("Channel config providerConnectionId does not match any providers"); }); it("throws error when no channel was found for channelSlug", () => { expect(() => - getActiveTaxProvider("invalid-channel", [ + getActiveConnection("invalid-channel", [ { key: "providers", value: mockedEncryptedProviders, @@ -118,11 +128,11 @@ describe("getActiveTaxProvider", () => { value: mockedValidEncryptedChannels, }, ]) - ).toThrow("Channel config not found for channel invalid-channel"); + ).toThrow("Channel config was not found for channel invalid-channel"); }); it("returns provider when data is correct", () => { - const result = getActiveTaxProvider("default-channel", [ + const result = getActiveConnection("default-channel", [ { key: "providers", value: mockedEncryptedProviders, diff --git a/apps/taxes/src/modules/taxes/active-connection.ts b/apps/taxes/src/modules/taxes/active-connection.ts new file mode 100644 index 0000000..d8cf197 --- /dev/null +++ b/apps/taxes/src/modules/taxes/active-connection.ts @@ -0,0 +1,100 @@ +import { + MetadataItem, + OrderCreatedSubscriptionFragment, + OrderFulfilledSubscriptionFragment, + TaxBaseFragment, +} from "../../../generated/graphql"; +import { Logger, createLogger } from "../../lib/logger"; + +import { getAppConfig } from "../app/get-app-config"; +import { AvataxWebhookService } from "../avatax/avatax-webhook.service"; +import { ProviderConnection } from "../provider-connections/provider-connections"; +import { TaxJarWebhookService } from "../taxjar/taxjar-webhook.service"; +import { ProviderWebhookService } from "./tax-provider-webhook"; + +// todo: refactor to a factory +export class ActiveTaxProvider implements ProviderWebhookService { + private logger: Logger; + private client: TaxJarWebhookService | AvataxWebhookService; + + constructor(providerConnection: ProviderConnection) { + this.logger = createLogger({ + location: "ActiveTaxProvider", + }); + + const taxProviderName = providerConnection.provider; + + switch (taxProviderName) { + case "taxjar": { + this.logger.debug("Selecting TaxJar as tax provider"); + this.client = new TaxJarWebhookService(providerConnection.config); + break; + } + + case "avatax": { + this.logger.debug("Selecting Avatax as tax provider"); + this.client = new AvataxWebhookService(providerConnection.config); + break; + } + + default: { + throw new Error(`Tax provider ${taxProviderName} doesn't match`); + } + } + } + + async calculateTaxes(payload: TaxBaseFragment) { + return this.client.calculateTaxes(payload); + } + + async createOrder(order: OrderCreatedSubscriptionFragment) { + return this.client.createOrder(order); + } + + async fulfillOrder(payload: OrderFulfilledSubscriptionFragment) { + return this.client.fulfillOrder(payload); + } +} + +export function getActiveConnection( + channelSlug: string | undefined, + encryptedMetadata: MetadataItem[] +): ActiveTaxProvider { + const logger = createLogger({ + location: "getActiveConnection", + }); + + if (!channelSlug) { + throw new Error("Channel slug was not found in the webhook payload"); + } + + if (!encryptedMetadata.length) { + throw new Error("App encryptedMetadata was not found in the webhook payload"); + } + + const { providerConnections, channels } = getAppConfig(encryptedMetadata); + + const channelConfig = channels.find((channel) => channel.config.slug === channelSlug); + + if (!channelConfig) { + // * will happen when `order-created` webhook is triggered by creating an order in a channel that doesn't use the tax app + logger.debug({ channelSlug, channelConfig }, "Channel config was not found for channel slug"); + throw new Error(`Channel config was not found for channel ${channelSlug}`); + } + + const providerConnection = providerConnections.find( + (connection) => connection.id === channelConfig.config.providerConnectionId + ); + + if (!providerConnection) { + logger.debug( + { providerConnections, channelConfig }, + "In the providers array, there is no item with an id that matches the channel config providerConnectionId." + ); + throw new Error(`Channel config providerConnectionId does not match any providers`); + } + + const taxProvider = new ActiveTaxProvider(providerConnection); + + return taxProvider; +} diff --git a/apps/taxes/src/modules/taxes/active-tax-provider.ts b/apps/taxes/src/modules/taxes/active-tax-provider.ts deleted file mode 100644 index 14df6ef..0000000 --- a/apps/taxes/src/modules/taxes/active-tax-provider.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { - MetadataItem, - OrderCreatedSubscriptionFragment, - OrderFulfilledSubscriptionFragment, - TaxBaseFragment, -} from "../../../generated/graphql"; -import { Logger, createLogger } from "../../lib/logger"; - -import { getAppConfig } from "../app/get-app-config"; -import { AvataxWebhookService } from "../avatax/avatax-webhook.service"; -import { ChannelConfig } from "../channels-configuration/channels-config"; -import { ProviderConfig } from "../providers-configuration/providers-config"; -import { TaxJarWebhookService } from "../taxjar/taxjar-webhook.service"; -import { ProviderWebhookService } from "./tax-provider-webhook"; - -// todo: refactor to a factory -export class ActiveTaxProvider implements ProviderWebhookService { - private client: ProviderWebhookService; - private logger: Logger; - private channel: ChannelConfig; - - constructor(providerInstance: ProviderConfig, channelConfig: ChannelConfig) { - this.logger = createLogger({ - service: "ActiveTaxProvider", - }); - - const taxProviderName = providerInstance.provider; - - this.logger.trace({ taxProviderName }, "Constructing tax provider: "); - this.channel = channelConfig; - - switch (taxProviderName) { - case "taxjar": - this.client = new TaxJarWebhookService(providerInstance.config); - break; - - case "avatax": - this.client = new AvataxWebhookService(providerInstance.config); - break; - - default: { - throw new Error(`Tax provider ${taxProviderName} doesn't match`); - } - } - } - - async calculateTaxes(payload: TaxBaseFragment) { - this.logger.trace({ payload }, ".calculate called"); - - return this.client.calculateTaxes(payload, this.channel); - } - - async createOrder(order: OrderCreatedSubscriptionFragment) { - this.logger.trace(".createOrder called"); - - return this.client.createOrder(order, this.channel); - } - - async fulfillOrder(payload: OrderFulfilledSubscriptionFragment) { - this.logger.trace(".fulfillOrder called"); - - return this.client.fulfillOrder(payload, this.channel); - } -} - -export function getActiveTaxProvider( - channelSlug: string | undefined, - encryptedMetadata: MetadataItem[] -): ActiveTaxProvider { - if (!channelSlug) { - throw new Error("Channel slug is missing"); - } - - if (!encryptedMetadata.length) { - throw new Error("App encryptedMetadata is missing"); - } - - const { providers, channels } = getAppConfig(encryptedMetadata); - - const channelConfig = channels[channelSlug]; - - if (!channelConfig) { - // * will happen when `order-created` webhook is triggered by creating an order in a channel that doesn't use the tax app - throw new Error(`Channel config not found for channel ${channelSlug}`); - } - - const providerInstance = providers.find( - (instance) => instance.id === channelConfig.providerInstanceId - ); - - if (!providerInstance) { - throw new Error(`Channel (${channelSlug}) providerInstanceId does not match any providers`); - } - - const taxProvider = new ActiveTaxProvider(providerInstance, channelConfig); - - return taxProvider; -} diff --git a/apps/taxes/src/modules/taxes/provider-config.ts b/apps/taxes/src/modules/taxes/provider-config.ts deleted file mode 100644 index 90aa289..0000000 --- a/apps/taxes/src/modules/taxes/provider-config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { AvataxIcon, TaxJarIcon } from "../../assets"; - -export const providerConfig = { - taxjar: { - label: "TaxJar", - icon: TaxJarIcon, - }, - avatax: { - label: "Avatax", - icon: AvataxIcon, - }, -}; - -export type TaxProviderName = keyof typeof providerConfig; diff --git a/apps/taxes/src/modules/taxes/tax-context.ts b/apps/taxes/src/modules/taxes/tax-context.ts deleted file mode 100644 index a635eb8..0000000 --- a/apps/taxes/src/modules/taxes/tax-context.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { atom, useAtom } from "jotai"; -import { AppTab } from "../../pages/configuration"; - -const channelSlugAtom = atom(""); - -export const useChannelSlug = () => { - const [channelSlug, setChannelSlug] = useAtom(channelSlugAtom); - - return { channelSlug, setChannelSlug }; -}; - -const instanceIdAtom = atom(null); - -export const useInstanceId = () => { - const [instanceId, setInstanceId] = useAtom(instanceIdAtom); - - return { instanceId, setInstanceId }; -}; - -const activeTabAtom = atom("channels"); - -export const useActiveTab = () => { - const [activeTab, setActiveTab] = useAtom(activeTabAtom); - - return { activeTab, setActiveTab }; -}; diff --git a/apps/taxes/src/modules/taxes/tax-provider-webhook.ts b/apps/taxes/src/modules/taxes/tax-provider-webhook.ts index 1b778ba..a516f60 100644 --- a/apps/taxes/src/modules/taxes/tax-provider-webhook.ts +++ b/apps/taxes/src/modules/taxes/tax-provider-webhook.ts @@ -4,23 +4,14 @@ import { OrderFulfilledSubscriptionFragment, TaxBaseFragment, } from "../../../generated/graphql"; -import { ChannelConfig } from "../channels-configuration/channels-config"; +import { ChannelConfig } from "../channel-configuration/channel-config"; export type CalculateTaxesResponse = SyncWebhookResponsesMap["ORDER_CALCULATE_TAXES"]; export type CreateOrderResponse = { id: string }; export interface ProviderWebhookService { - calculateTaxes: ( - payload: TaxBaseFragment, - channel: ChannelConfig - ) => Promise; - createOrder: ( - payload: OrderCreatedSubscriptionFragment, - channel: ChannelConfig - ) => Promise; - fulfillOrder: ( - payload: OrderFulfilledSubscriptionFragment, - channel: ChannelConfig - ) => Promise<{ ok: boolean }>; + calculateTaxes: (payload: TaxBaseFragment) => Promise; + createOrder: (payload: OrderCreatedSubscriptionFragment) => Promise; + fulfillOrder: (payload: OrderFulfilledSubscriptionFragment) => Promise<{ ok: boolean }>; } diff --git a/apps/taxes/src/modules/taxjar/address-factory.test.ts b/apps/taxes/src/modules/taxjar/address-factory.test.ts index 335eb64..bd8e6ff 100644 --- a/apps/taxes/src/modules/taxjar/address-factory.test.ts +++ b/apps/taxes/src/modules/taxjar/address-factory.test.ts @@ -4,7 +4,7 @@ import { taxJarAddressFactory } from "./address-factory"; describe("taxJarAddressFactory", () => { describe("fromChannelAddress", () => { it("returns fields in the expected format", () => { - const result = taxJarAddressFactory.fromChannelAddress({ + const result = taxJarAddressFactory.fromChannelToTax({ city: "LOS ANGELES", country: "US", state: "CA", @@ -24,7 +24,7 @@ describe("taxJarAddressFactory", () => { describe("fromSaleorAddress", () => { it("returns fields in the expected format with streetAddress1", () => { - const result = taxJarAddressFactory.fromSaleorAddress({ + const result = taxJarAddressFactory.fromSaleorToTax({ streetAddress1: "123 Palm Grove Ln", streetAddress2: "", city: "LOS ANGELES", @@ -45,7 +45,7 @@ describe("taxJarAddressFactory", () => { }); it("returns fields in the expected format with streetAddress1 and streetAddress2", () => { - const result = taxJarAddressFactory.fromSaleorAddress({ + const result = taxJarAddressFactory.fromSaleorToTax({ streetAddress1: "123 Palm", streetAddress2: "Grove Ln", city: "LOS ANGELES", diff --git a/apps/taxes/src/modules/taxjar/address-factory.ts b/apps/taxes/src/modules/taxjar/address-factory.ts index e6e41de..b9ab37c 100644 --- a/apps/taxes/src/modules/taxjar/address-factory.ts +++ b/apps/taxes/src/modules/taxjar/address-factory.ts @@ -1,6 +1,7 @@ -import { ChannelAddress } from "../channels-configuration/channels-config"; +import { TaxParams } from "taxjar/dist/types/paramTypes"; import { AddressFragment as SaleorAddress } from "../../../generated/graphql"; -import { AddressParams as TaxJarAddress, TaxParams } from "taxjar/dist/types/paramTypes"; +import { TaxJarConfig } from "./taxjar-connection-schema"; +import { AddressParams } from "taxjar/dist/types/paramTypes"; function joinAddresses(address1: string, address2: string): string { return `${address1}${address2.length > 0 ? " " + address2 : ""}`; @@ -19,7 +20,7 @@ function mapSaleorAddressToTaxJarAddress( } function mapChannelAddressToTaxJarAddress( - address: ChannelAddress + address: TaxJarConfig["address"] ): Pick { return { from_city: address.city, @@ -30,7 +31,18 @@ function mapChannelAddressToTaxJarAddress( }; } +function mapChannelAddressToAddressParams(address: TaxJarConfig["address"]): AddressParams { + return { + city: address.city, + country: address.country, + state: address.state, + street: address.street, + zip: address.zip, + }; +} + export const taxJarAddressFactory = { - fromSaleorAddress: mapSaleorAddressToTaxJarAddress, - fromChannelAddress: mapChannelAddressToTaxJarAddress, + fromSaleorToTax: mapSaleorAddressToTaxJarAddress, + fromChannelToTax: mapChannelAddressToTaxJarAddress, + fromChannelToParams: mapChannelAddressToAddressParams, }; diff --git a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-adapter.ts b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-adapter.ts index 03aaeb5..7763d43 100644 --- a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-adapter.ts +++ b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-adapter.ts @@ -1,38 +1,41 @@ import { TaxBaseFragment } from "../../../../generated/graphql"; -import { ChannelConfig } from "../../channels-configuration/channels-config"; +import { Logger, createLogger } from "../../../lib/logger"; import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook"; -import { FetchTaxForOrderArgs, TaxJarClient } from "../taxjar-client"; -import { TaxJarConfig } from "../taxjar-config"; import { WebhookAdapter } from "../../taxes/tax-webhook-adapter"; +import { FetchTaxForOrderArgs, TaxJarClient } from "../taxjar-client"; +import { TaxJarConfig } from "../taxjar-connection-schema"; import { TaxJarCalculateTaxesPayloadTransformer } from "./taxjar-calculate-taxes-payload-transformer"; import { TaxJarCalculateTaxesResponseTransformer } from "./taxjar-calculate-taxes-response-transformer"; -import { Logger, createLogger } from "../../../lib/logger"; -export type Payload = { +export type TaxJarCalculateTaxesPayload = { taxBase: TaxBaseFragment; - channelConfig: ChannelConfig; }; -export type Target = FetchTaxForOrderArgs; -export type Response = CalculateTaxesResponse; +export type TaxJarCalculateTaxesTarget = FetchTaxForOrderArgs; +export type TaxJarCalculateTaxesResponse = CalculateTaxesResponse; -export class TaxJarCalculateTaxesAdapter implements WebhookAdapter { +export class TaxJarCalculateTaxesAdapter + implements WebhookAdapter +{ private logger: Logger; constructor(private readonly config: TaxJarConfig) { - this.logger = createLogger({ service: "TaxJarCalculateTaxesAdapter" }); + this.logger = createLogger({ location: "TaxJarCalculateTaxesAdapter" }); } - async send(payload: Payload): Promise { - this.logger.debug({ payload }, "send called with:"); - const payloadTransformer = new TaxJarCalculateTaxesPayloadTransformer(); + async send(payload: TaxJarCalculateTaxesPayload): Promise { + this.logger.debug({ payload }, "Transforming the following Saleor payload:"); + const payloadTransformer = new TaxJarCalculateTaxesPayloadTransformer(this.config); const target = payloadTransformer.transform(payload); - this.logger.debug({ transformedPayload: target }, "Will call fetchTaxForOrder with:"); + this.logger.debug( + { transformedPayload: target }, + "Will call TaxJar fetchTaxForOrder with transformed payload:" + ); const client = new TaxJarClient(this.config); const response = await client.fetchTaxForOrder(target); - this.logger.debug({ response }, "TaxJar fetchTaxForOrder response:"); + this.logger.debug({ response }, "TaxJar fetchTaxForOrder responded with:"); const responseTransformer = new TaxJarCalculateTaxesResponseTransformer(); const transformedResponse = responseTransformer.transform(payload, response); diff --git a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-mock-generator.ts b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-mock-generator.ts index 51a959d..35394c2 100644 --- a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-mock-generator.ts +++ b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-mock-generator.ts @@ -1,6 +1,7 @@ import { TaxForOrderRes } from "taxjar/dist/types/returnTypes"; import { TaxBaseFragment } from "../../../../generated/graphql"; -import { ChannelConfig } from "../../channels-configuration/channels-config"; +import { ChannelConfig } from "../../channel-configuration/channel-config"; +import { TaxJarConfig } from "../taxjar-connection-schema"; type TaxBase = TaxBaseFragment; @@ -194,21 +195,20 @@ const taxExcludedTaxBase: TaxBase = { }, }; -const withNexusChannelConfig: ChannelConfig = { - providerInstanceId: "b8c29f49-7cae-4762-8458-e9a27eb83081", - enabled: false, - address: { - country: "US", - zip: "10118", - state: "NY", - city: "New York", - street: "350 5th Avenue", +const channelConfig: ChannelConfig = { + id: "1", + config: { + providerConnectionId: "b8c29f49-7cae-4762-8458-e9a27eb83081", + slug: "default-channel", }, }; -const noNexusChannelConfig: ChannelConfig = { - providerInstanceId: "aa5293e5-7f5d-4782-a619-222ead918e50", - enabled: false, +const providerConfig: TaxJarConfig = { + name: "taxjar-1", + isSandbox: false, + credentials: { + apiKey: "test", + }, address: { country: "US", zip: "10118", @@ -461,22 +461,26 @@ const withNexusTaxIncludedTaxForOrderMock: TaxForOrder = { const testingScenariosMap = { with_no_nexus_tax_included: { taxBase: taxIncludedTaxBase, - channelConfig: noNexusChannelConfig, + channelConfig, + providerConfig, response: noNexusTaxForOrderMock, }, with_no_nexus_tax_excluded: { taxBase: taxExcludedTaxBase, - channelConfig: noNexusChannelConfig, + channelConfig, + providerConfig, response: noNexusTaxForOrderMock, }, with_nexus_tax_included: { taxBase: taxIncludedTaxBase, - channelConfig: withNexusChannelConfig, + channelConfig, + providerConfig, response: withNexusTaxIncludedTaxForOrderMock, }, with_nexus_tax_excluded: { taxBase: taxExcludedTaxBase, - channelConfig: withNexusChannelConfig, + channelConfig, + providerConfig, response: withNexusTaxExcludedTaxForOrderMock, }, }; @@ -497,6 +501,12 @@ export class TaxJarCalculateTaxesMockGenerator { ...overrides, }); + generateProviderConfig = (overrides: Partial = {}): TaxJarConfig => + structuredClone({ + ...testingScenariosMap[this.scenario].providerConfig, + ...overrides, + }); + generateResponse = (overrides: Partial = {}): TaxForOrder => structuredClone({ ...testingScenariosMap[this.scenario].response, diff --git a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-payload-transformer.test.ts b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-payload-transformer.test.ts index c1b4b2a..67855c9 100644 --- a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-payload-transformer.test.ts +++ b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-payload-transformer.test.ts @@ -2,16 +2,15 @@ import { describe, expect, it } from "vitest"; import { TaxJarCalculateTaxesMockGenerator } from "./taxjar-calculate-taxes-mock-generator"; import { TaxJarCalculateTaxesPayloadTransformer } from "./taxjar-calculate-taxes-payload-transformer"; -const transformer = new TaxJarCalculateTaxesPayloadTransformer(); - describe("TaxJarCalculateTaxesPayloadTransformer", () => { + const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included"); + const providerConfig = mockGenerator.generateProviderConfig(); + const transformer = new TaxJarCalculateTaxesPayloadTransformer(providerConfig); + it("returns payload containing line_items without discounts", () => { - const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included"); const taxBase = mockGenerator.generateTaxBase(); - const channelConfig = mockGenerator.generateChannelConfig(); const transformedPayload = transformer.transform({ taxBase, - channelConfig, }); expect(transformedPayload).toEqual({ @@ -62,10 +61,8 @@ describe("TaxJarCalculateTaxesPayloadTransformer", () => { }, ], }); - const channelConfig = mockGenerator.generateChannelConfig(); const transformedPayload = transformer.transform({ taxBase, - channelConfig, }); const payloadLines = transformedPayload.params.line_items ?? []; @@ -100,12 +97,10 @@ describe("TaxJarCalculateTaxesPayloadTransformer", () => { it("throws error when no address", () => { const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included"); const taxBase = mockGenerator.generateTaxBase({ address: null }); - const channelConfig = mockGenerator.generateChannelConfig(); expect(() => transformer.transform({ taxBase, - channelConfig, }) ).toThrow("Customer address is required to calculate taxes in TaxJar."); }); diff --git a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-payload-transformer.ts b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-payload-transformer.ts index 67d5865..a18097a 100644 --- a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-payload-transformer.ts +++ b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-payload-transformer.ts @@ -1,9 +1,16 @@ import { discountUtils } from "../../taxes/discount-utils"; import { taxJarAddressFactory } from "../address-factory"; -import { Payload, Target } from "./taxjar-calculate-taxes-adapter"; +import { TaxJarConfig } from "../taxjar-connection-schema"; +import { + TaxJarCalculateTaxesPayload, + TaxJarCalculateTaxesTarget, +} from "./taxjar-calculate-taxes-adapter"; export class TaxJarCalculateTaxesPayloadTransformer { - private mapLines(taxBase: Payload["taxBase"]): Target["params"]["line_items"] { + constructor(private readonly config: TaxJarConfig) {} + private mapLines( + taxBase: TaxJarCalculateTaxesPayload["taxBase"] + ): TaxJarCalculateTaxesTarget["params"]["line_items"] { const { lines, discounts } = taxBase; const discountSum = discounts?.reduce( (total, current) => total + Number(current.amount.amount), @@ -12,32 +19,34 @@ export class TaxJarCalculateTaxesPayloadTransformer { const linePrices = lines.map((line) => Number(line.totalPrice.amount)); const distributedDiscounts = discountUtils.distributeDiscount(discountSum, linePrices); - const mappedLines: Target["params"]["line_items"] = lines.map((line, index) => { - const discountAmount = distributedDiscounts[index]; + const mappedLines: TaxJarCalculateTaxesTarget["params"]["line_items"] = lines.map( + (line, index) => { + const discountAmount = distributedDiscounts[index]; - return { - id: line.sourceLine.id, - // todo: get from tax code matcher - product_tax_code: "", - quantity: line.quantity, - unit_price: Number(line.unitPrice.amount), - discount: discountAmount, - }; - }); + return { + id: line.sourceLine.id, + // todo: get from tax code matcher + product_tax_code: "", + quantity: line.quantity, + unit_price: Number(line.unitPrice.amount), + discount: discountAmount, + }; + } + ); return mappedLines; } - transform({ taxBase, channelConfig }: Payload): Target { - const fromAddress = taxJarAddressFactory.fromChannelAddress(channelConfig.address); + transform({ taxBase }: TaxJarCalculateTaxesPayload): TaxJarCalculateTaxesTarget { + const fromAddress = taxJarAddressFactory.fromChannelToTax(this.config.address); if (!taxBase.address) { throw new Error("Customer address is required to calculate taxes in TaxJar."); } - const toAddress = taxJarAddressFactory.fromSaleorAddress(taxBase.address); + const toAddress = taxJarAddressFactory.fromSaleorToTax(taxBase.address); - const taxParams: Target = { + const taxParams: TaxJarCalculateTaxesTarget = { params: { ...fromAddress, ...toAddress, diff --git a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-lines-transformer.ts b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-lines-transformer.ts index 3b9c26a..efc0fb2 100644 --- a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-lines-transformer.ts +++ b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-lines-transformer.ts @@ -2,7 +2,10 @@ import Breakdown from "taxjar/dist/types/breakdown"; import { TaxForOrderRes } from "taxjar/dist/types/returnTypes"; import { TaxBaseFragment } from "../../../../generated/graphql"; import { taxProviderUtils } from "../../taxes/tax-provider-utils"; -import { Payload, Response } from "./taxjar-calculate-taxes-adapter"; +import { + TaxJarCalculateTaxesPayload, + TaxJarCalculateTaxesResponse, +} from "./taxjar-calculate-taxes-adapter"; /* * TaxJar doesn't guarantee the order of the response items to match the payload items order. @@ -26,7 +29,10 @@ export function matchPayloadLinesToResponseLines( } export class TaxJarCalculateTaxesResponseLinesTransformer { - transform(payload: Payload, response: TaxForOrderRes): Response["lines"] { + transform( + payload: TaxJarCalculateTaxesPayload, + response: TaxForOrderRes + ): TaxJarCalculateTaxesResponse["lines"] { const responseLines = response.tax.breakdown?.line_items ?? []; const lines = matchPayloadLinesToResponseLines(payload.taxBase.lines, responseLines); diff --git a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-shipping-transformer.ts b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-shipping-transformer.ts index ae388a1..e8496a9 100644 --- a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-shipping-transformer.ts +++ b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-shipping-transformer.ts @@ -1,13 +1,16 @@ import { TaxForOrderRes } from "taxjar/dist/types/returnTypes"; import { numbers } from "../../taxes/numbers"; -import { Payload, Response } from "./taxjar-calculate-taxes-adapter"; +import { + TaxJarCalculateTaxesResponse, + TaxJarCalculateTaxesPayload, +} from "./taxjar-calculate-taxes-adapter"; export class TaxJarCalculateTaxesResponseShippingTransformer { transform( - taxBase: Payload["taxBase"], + taxBase: TaxJarCalculateTaxesPayload["taxBase"], res: TaxForOrderRes ): Pick< - Response, + TaxJarCalculateTaxesResponse, "shipping_price_gross_amount" | "shipping_price_net_amount" | "shipping_tax_rate" > { const { tax } = res; diff --git a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-transformer.test.ts b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-transformer.test.ts index db0e519..0851bee 100644 --- a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-transformer.test.ts +++ b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-transformer.test.ts @@ -10,7 +10,7 @@ describe("TaxJarCalculateTaxesResponseTransformer", () => { const noNexusResponseMock = mockGenerator.generateResponse(); const payloadMock = { taxBase: mockGenerator.generateTaxBase(), - channelConfig: mockGenerator.generateChannelConfig(), + providerConfig: mockGenerator.generateProviderConfig(), }; const result = transformer.transform(payloadMock, noNexusResponseMock); @@ -44,7 +44,7 @@ describe("TaxJarCalculateTaxesResponseTransformer", () => { const payloadMock = { taxBase: mockGenerator.generateTaxBase(), - channelConfig: mockGenerator.generateChannelConfig(), + providerConfig: mockGenerator.generateProviderConfig(), }; const result = transformer.transform(payloadMock, nexusResponse); diff --git a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-transformer.ts b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-transformer.ts index 8a0523c..2b18b2a 100644 --- a/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-transformer.ts +++ b/apps/taxes/src/modules/taxjar/calculate-taxes/taxjar-calculate-taxes-response-transformer.ts @@ -1,6 +1,9 @@ import { TaxForOrderRes } from "taxjar/dist/types/returnTypes"; import { Logger, createLogger } from "../../../lib/logger"; -import { Payload, Response } from "./taxjar-calculate-taxes-adapter"; +import { + TaxJarCalculateTaxesResponse, + TaxJarCalculateTaxesPayload, +} from "./taxjar-calculate-taxes-adapter"; import { TaxJarCalculateTaxesResponseLinesTransformer } from "./taxjar-calculate-taxes-response-lines-transformer"; import { TaxJarCalculateTaxesResponseShippingTransformer } from "./taxjar-calculate-taxes-response-shipping-transformer"; @@ -11,7 +14,10 @@ export class TaxJarCalculateTaxesResponseTransformer { this.logger = createLogger({ name: "TaxJarCalculateTaxesResponseTransformer" }); } - transform(payload: Payload, response: TaxForOrderRes): Response { + transform( + payload: TaxJarCalculateTaxesPayload, + response: TaxForOrderRes + ): TaxJarCalculateTaxesResponse { /* * TaxJar operates on the idea of sales tax nexus. Nexus is a place where the company has a physical presence. * If the company has no nexus in the state where the customer is located, the company is not required to collect sales tax. diff --git a/apps/taxes/src/modules/taxjar/configuration/public-taxjar-connection.service.ts b/apps/taxes/src/modules/taxjar/configuration/public-taxjar-connection.service.ts new file mode 100644 index 0000000..a22da58 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/configuration/public-taxjar-connection.service.ts @@ -0,0 +1,38 @@ +import { DeepPartial } from "@trpc/server"; +import { Client } from "urql"; +import { TaxJarConfig } from "../taxjar-connection-schema"; +import { TaxJarConnectionObfuscator } from "./taxjar-connection-obfuscator"; +import { TaxJarConnectionService } from "./taxjar-connection.service"; + +export class PublicTaxJarConnectionService { + private readonly connectionService: TaxJarConnectionService; + private readonly obfuscator = new TaxJarConnectionObfuscator(); + constructor(client: Client, appId: string, saleorApiUrl: string) { + this.connectionService = new TaxJarConnectionService(client, appId, saleorApiUrl); + this.obfuscator = new TaxJarConnectionObfuscator(); + } + + async getAll() { + const connections = await this.connectionService.getAll(); + + return this.obfuscator.obfuscateTaxJarConnections(connections); + } + + async getById(id: string) { + const connection = await this.connectionService.getById(id); + + return this.obfuscator.obfuscateTaxJarConnection(connection); + } + + async create(config: TaxJarConfig) { + return this.connectionService.create(config); + } + + async update(id: string, config: DeepPartial) { + return this.connectionService.update(id, config); + } + + async delete(id: string) { + return this.connectionService.delete(id); + } +} diff --git a/apps/taxes/src/modules/taxjar/configuration/tax-jar-validation-error-resolver.test.ts b/apps/taxes/src/modules/taxjar/configuration/tax-jar-validation-error-resolver.test.ts new file mode 100644 index 0000000..b91aa62 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/configuration/tax-jar-validation-error-resolver.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { TaxJarValidationErrorResolver } from "./tax-jar-validation-error-resolver"; +import { TaxjarError } from "taxjar/dist/util/types"; + +describe("TaxJarValidationErrorResolver", () => { + const erroResolver = new TaxJarValidationErrorResolver(); + + it("when not a TaxjarError, should return error with generic message", () => { + const result = erroResolver.resolve("error"); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe("Unknown error while validating TaxJar configuration."); + }); + it("when TaxJarError && status 401, should return error with specific message", () => { + const error = new TaxjarError("error", "detail", 401); + + const result = erroResolver.resolve(error); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe( + "The provided API token is invalid. Please visit https://support.taxjar.com/article/160-how-do-i-get-a-sales-tax-api-token for more information." + ); + }); + it("when TaxJarError && status 404, should return error with specific message", () => { + const error = new TaxjarError("error", "detail", 404); + + const result = erroResolver.resolve(error); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe( + "The provided address is invalid. Please visit https://support.taxjar.com/article/659-address-validation to learn about address formatting." + ); + }); + it("when TaxJarError && other status, should return error with error detail message", () => { + const error = new TaxjarError("error", "passed error message", 500); + + const result = erroResolver.resolve(error); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe("passed error message"); + }); +}); diff --git a/apps/taxes/src/modules/taxjar/configuration/tax-jar-validation-error-resolver.ts b/apps/taxes/src/modules/taxjar/configuration/tax-jar-validation-error-resolver.ts new file mode 100644 index 0000000..c575380 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/configuration/tax-jar-validation-error-resolver.ts @@ -0,0 +1,23 @@ +import { TaxjarError } from "taxjar/dist/util/types"; + +export class TaxJarValidationErrorResolver { + resolve(error: unknown): Error { + if (!(error instanceof TaxjarError)) { + return new Error("Unknown error while validating TaxJar configuration."); + } + + if (error.status === 401) { + return new Error( + "The provided API token is invalid. Please visit https://support.taxjar.com/article/160-how-do-i-get-a-sales-tax-api-token for more information." + ); + } + + if (error.status === 404) { + return new Error( + "The provided address is invalid. Please visit https://support.taxjar.com/article/659-address-validation to learn about address formatting." + ); + } + + return new Error(error.detail); + } +} diff --git a/apps/taxes/src/modules/taxjar/configuration/taxjar-connection-obfuscator.test.ts b/apps/taxes/src/modules/taxjar/configuration/taxjar-connection-obfuscator.test.ts new file mode 100644 index 0000000..99867c1 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/configuration/taxjar-connection-obfuscator.test.ts @@ -0,0 +1,27 @@ +import { TaxJarConfigMockGenerator } from "../taxjar-config-mock-generator"; +import { TaxJarConnectionObfuscator } from "./taxjar-connection-obfuscator"; +import { expect, it, describe } from "vitest"; + +const mockTaxJarConfig = new TaxJarConfigMockGenerator().generateTaxJarConfig(); +const obfuscator = new TaxJarConnectionObfuscator(); + +describe("TaxJarConnectionObfuscator", () => { + it("obfuscated taxjar config", () => { + const obfuscatedConfig = obfuscator.obfuscateTaxJarConfig(mockTaxJarConfig); + + expect(obfuscatedConfig).toEqual({ + ...mockTaxJarConfig, + credentials: { + apiKey: "***********iKey", + }, + }); + }); + it("filters out obfuscated", () => { + const obfuscatedConfig = obfuscator.obfuscateTaxJarConfig(mockTaxJarConfig); + const { credentials, ...rest } = obfuscatedConfig; + + const filteredConfig = obfuscator.filterOutObfuscated(obfuscatedConfig); + + expect(filteredConfig).toEqual(rest); + }); +}); diff --git a/apps/taxes/src/modules/taxjar/configuration/taxjar-connection-obfuscator.ts b/apps/taxes/src/modules/taxjar/configuration/taxjar-connection-obfuscator.ts new file mode 100644 index 0000000..2768581 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/configuration/taxjar-connection-obfuscator.ts @@ -0,0 +1,34 @@ +import { Obfuscator } from "../../../lib/obfuscator"; +import { TaxJarConfig, TaxJarConnection } from "../taxjar-connection-schema"; + +export class TaxJarConnectionObfuscator { + private obfuscator = new Obfuscator(); + obfuscateTaxJarConfig = (config: TaxJarConfig): TaxJarConfig => { + return { + ...config, + credentials: { + ...config.credentials, + apiKey: this.obfuscator.obfuscate(config.credentials.apiKey), + }, + }; + }; + + obfuscateTaxJarConnection = (connection: TaxJarConnection): TaxJarConnection => ({ + ...connection, + config: this.obfuscateTaxJarConfig(connection.config), + }); + + obfuscateTaxJarConnections = (connections: TaxJarConnection[]): TaxJarConnection[] => + connections.map(this.obfuscateTaxJarConnection); + + filterOutObfuscated = (data: TaxJarConfig) => { + const { credentials, ...rest } = data; + const isApiKeyObfuscated = this.obfuscator.isObfuscated(credentials.apiKey); + + if (isApiKeyObfuscated) { + return rest; + } + + return data; + }; +} diff --git a/apps/taxes/src/modules/taxjar/configuration/taxjar-connection-repository.ts b/apps/taxes/src/modules/taxjar/configuration/taxjar-connection-repository.ts new file mode 100644 index 0000000..3246911 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/configuration/taxjar-connection-repository.ts @@ -0,0 +1,89 @@ +import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager"; +import { TaxProvidersV1toV2MigrationManager } from "../../../../scripts/migrations/tax-providers-migration-v1-to-v2"; +import { createLogger, Logger } from "../../../lib/logger"; +import { CrudSettingsManager } from "../../crud-settings/crud-settings.service"; +import { + ProviderConnections, + providerConnectionsSchema, +} from "../../provider-connections/provider-connections"; +import { TAX_PROVIDER_KEY } from "../../provider-connections/public-provider-connections.service"; +import { TaxJarConfig, TaxJarConnection, taxJarConnection } from "../taxjar-connection-schema"; + +const getSchema = taxJarConnection; + +export class TaxJarConnectionRepository { + private crudSettingsManager: CrudSettingsManager; + private logger: Logger; + constructor(private settingsManager: EncryptedMetadataManager, private saleorApiUrl: string) { + this.crudSettingsManager = new CrudSettingsManager( + settingsManager, + saleorApiUrl, + TAX_PROVIDER_KEY + ); + this.logger = createLogger({ + location: "TaxJarConnectionRepository", + metadataKey: TAX_PROVIDER_KEY, + }); + } + + private filterTaxJarConnections(connections: ProviderConnections): TaxJarConnection[] { + return connections.filter( + (connection) => connection.provider === "taxjar" + ) as TaxJarConnection[]; + } + + async getAll(): Promise { + const { data } = await this.crudSettingsManager.readAll(); + /* + * * migration logic start + * // todo: remove after migration + */ + const migrationManager = new TaxProvidersV1toV2MigrationManager( + this.settingsManager, + this.saleorApiUrl + ); + + const migratedConfig = await migrationManager.migrateIfNeeded(); + + if (migratedConfig) { + this.logger.info("Config migrated", migratedConfig); + return this.filterTaxJarConnections(migratedConfig); + } + + this.logger.info("Config is up to date, no need to migrate."); + /* + * * migration logic end + */ + + const connections = providerConnectionsSchema.parse(data); + + const taxJarConnections = this.filterTaxJarConnections(connections); + + return taxJarConnections; + } + + async get(id: string): Promise { + const { data } = await this.crudSettingsManager.read(id); + + const connection = getSchema.parse(data); + + return connection; + } + + async post(config: TaxJarConfig): Promise<{ id: string }> { + const result = await this.crudSettingsManager.create({ + provider: "taxjar", + config: config, + }); + + return result.data; + } + + async patch(id: string, input: TaxJarConfig): Promise { + return this.crudSettingsManager.update(id, input); + } + + async delete(id: string): Promise { + return this.crudSettingsManager.delete(id); + } +} diff --git a/apps/taxes/src/modules/taxjar/configuration/taxjar-connection.service.ts b/apps/taxes/src/modules/taxjar/configuration/taxjar-connection.service.ts new file mode 100644 index 0000000..74ce1db --- /dev/null +++ b/apps/taxes/src/modules/taxjar/configuration/taxjar-connection.service.ts @@ -0,0 +1,58 @@ +import { Client } from "urql"; +import { Logger, createLogger } from "../../../lib/logger"; +import { TaxJarConnectionRepository } from "./taxjar-connection-repository"; +import { TaxJarConfig, TaxJarConnection } from "../taxjar-connection-schema"; +import { TaxJarValidationService } from "./taxjar-validation.service"; +import { DeepPartial } from "@trpc/server"; +import { PatchInputTransformer } from "../../provider-connections/patch-input-transformer"; +import { createSettingsManager } from "../../app/metadata-manager"; + +export class TaxJarConnectionService { + private logger: Logger; + private taxJarConnectionRepository: TaxJarConnectionRepository; + constructor(client: Client, appId: string, saleorApiUrl: string) { + this.logger = createLogger({ + location: "TaxJarConnectionService", + }); + + const settingsManager = createSettingsManager(client, appId); + + this.taxJarConnectionRepository = new TaxJarConnectionRepository(settingsManager, saleorApiUrl); + } + + getAll(): Promise { + return this.taxJarConnectionRepository.getAll(); + } + + getById(id: string): Promise { + return this.taxJarConnectionRepository.get(id); + } + + async create(config: TaxJarConfig): Promise<{ id: string }> { + const validationService = new TaxJarValidationService(); + + await validationService.validate(config); + + return await this.taxJarConnectionRepository.post(config); + } + + async update(id: string, nextConfigPartial: DeepPartial): Promise { + const data = await this.getById(id); + // omit the key "id" from the result + const { id: _, ...setting } = data; + const prevConfig = setting.config; + + const validationService = new TaxJarValidationService(); + const inputTransformer = new PatchInputTransformer(); + + const input = inputTransformer.transform(nextConfigPartial, prevConfig); + + await validationService.validate(input); + + return this.taxJarConnectionRepository.patch(id, input); + } + + async delete(id: string): Promise { + return this.taxJarConnectionRepository.delete(id); + } +} diff --git a/apps/taxes/src/modules/taxjar/configuration/taxjar-validation.service.ts b/apps/taxes/src/modules/taxjar/configuration/taxjar-validation.service.ts new file mode 100644 index 0000000..77fbf09 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/configuration/taxjar-validation.service.ts @@ -0,0 +1,21 @@ +import { taxJarAddressFactory } from "../address-factory"; +import { TaxJarClient } from "../taxjar-client"; +import { TaxJarConfig } from "../taxjar-connection-schema"; +import { TaxJarValidationErrorResolver } from "./tax-jar-validation-error-resolver"; + +export class TaxJarValidationService { + async validate(config: TaxJarConfig): Promise { + const taxJarClient = new TaxJarClient(config); + + const address = taxJarAddressFactory.fromChannelToParams(config.address); + + try { + // if the address is invalid, TaxJar will throw an error (rather than 200 and error messages) + await taxJarClient.validateAddress({ params: address }); + } catch (error) { + const errorResolver = new TaxJarValidationErrorResolver(); + + throw errorResolver.resolve(error); + } + } +} diff --git a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-adapter.ts b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-adapter.ts index 61ca293..b5254f5 100644 --- a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-adapter.ts +++ b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-adapter.ts @@ -1,34 +1,41 @@ +import { CreateOrderRes } from "taxjar/dist/types/returnTypes"; import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql"; -import { ChannelConfig } from "../../channels-configuration/channels-config"; +import { Logger, createLogger } from "../../../lib/logger"; import { CreateOrderResponse } from "../../taxes/tax-provider-webhook"; import { WebhookAdapter } from "../../taxes/tax-webhook-adapter"; -import { TaxJarOrderCreatedPayloadTransformer } from "./taxjar-order-created-payload-transformer"; import { CreateOrderArgs, TaxJarClient } from "../taxjar-client"; -import { TaxJarConfig } from "../taxjar-config"; +import { TaxJarConfig } from "../taxjar-connection-schema"; +import { TaxJarOrderCreatedPayloadTransformer } from "./taxjar-order-created-payload-transformer"; import { TaxJarOrderCreatedResponseTransformer } from "./taxjar-order-created-response-transformer"; -import { Logger, createLogger } from "../../../lib/logger"; -export type Payload = { order: OrderCreatedSubscriptionFragment; channelConfig: ChannelConfig }; -export type Target = CreateOrderArgs; -type Response = CreateOrderResponse; +export type TaxJarOrderCreatedPayload = { + order: OrderCreatedSubscriptionFragment; +}; +export type TaxJarOrderCreatedTarget = CreateOrderArgs; +export type TaxJarOrderCreatedResponse = CreateOrderResponse; -export class TaxJarOrderCreatedAdapter implements WebhookAdapter { +export class TaxJarOrderCreatedAdapter + implements WebhookAdapter +{ private logger: Logger; constructor(private readonly config: TaxJarConfig) { - this.logger = createLogger({ service: "TaxJarOrderCreatedAdapter" }); + this.logger = createLogger({ location: "TaxJarOrderCreatedAdapter" }); } - async send(payload: Payload): Promise { - this.logger.debug({ payload }, "send called with:"); - - const payloadTransformer = new TaxJarOrderCreatedPayloadTransformer(); + async send(payload: TaxJarOrderCreatedPayload): Promise { + this.logger.debug({ payload }, "Transforming the following Saleor payload:"); + const payloadTransformer = new TaxJarOrderCreatedPayloadTransformer(this.config); const target = payloadTransformer.transform(payload); + this.logger.debug( + { transformedPayload: target }, + "Will call TaxJar fetchTaxForOrder with transformed payload:" + ); + const client = new TaxJarClient(this.config); const response = await client.createOrder(target); - this.logger.debug({ response }, "TaxJar createOrder response:"); - + this.logger.debug({ response }, "TaxJar createOrder responded with:"); const responseTransformer = new TaxJarOrderCreatedResponseTransformer(); const transformedResponse = responseTransformer.transform(response); diff --git a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-mock-generator.ts b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-mock-generator.ts index 35f9cb5..1a1aa74 100644 --- a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-mock-generator.ts +++ b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-mock-generator.ts @@ -1,13 +1,19 @@ import { CreateOrderRes } from "taxjar/dist/types/returnTypes"; import { OrderCreatedSubscriptionFragment, OrderStatus } from "../../../../generated/graphql"; -import { ChannelConfig } from "../../channels-configuration/channels-config"; +import { ChannelConfig } from "../../channel-configuration/channel-config"; import { defaultOrder } from "../../../mocks"; +import { TaxJarConfig } from "../taxjar-connection-schema"; +import { ChannelConfigMockGenerator } from "../../channel-configuration/channel-config-mock-generator"; type Order = OrderCreatedSubscriptionFragment; -const defaultChannelConfig: ChannelConfig = { - providerInstanceId: "aa5293e5-7f5d-4782-a619-222ead918e50", - enabled: false, +// providerConfigMockGenerator class that other classes extend? +const defaultProviderConfig: TaxJarConfig = { + name: "taxjar-1", + credentials: { + apiKey: "test", + }, + isSandbox: false, address: { country: "US", zip: "95008", @@ -76,8 +82,8 @@ const defaultOrderCreatedResponse: CreateOrderRes = { const testingScenariosMap = { default: { order: defaultOrder, - channelConfig: defaultChannelConfig, response: defaultOrderCreatedResponse, + providerConfig: defaultProviderConfig, }, }; @@ -91,15 +97,21 @@ export class TaxJarOrderCreatedMockGenerator { ...overrides, }); - generateChannelConfig = (overrides: Partial = {}): ChannelConfig => - structuredClone({ - ...testingScenariosMap[this.scenario].channelConfig, - ...overrides, - }); + generateChannelConfig = (overrides: Partial = {}): ChannelConfig => { + const mockGenerator = new ChannelConfigMockGenerator(); + + return mockGenerator.generateChannelConfig(overrides); + }; generateResponse = (overrides: Partial = {}): CreateOrderRes => structuredClone({ ...testingScenariosMap[this.scenario].response, ...overrides, }); + + generateProviderConfig = (overrides: Partial = {}): TaxJarConfig => + structuredClone({ + ...testingScenariosMap[this.scenario].providerConfig, + ...overrides, + }); } diff --git a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.test.ts b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.test.ts index e94484d..b03cff3 100644 --- a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.test.ts +++ b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.test.ts @@ -11,9 +11,9 @@ describe("TaxJarOrderCreatedPayloadTransformer", () => { it("returns the correct order amount", () => { const payloadMock = { order: mockGenerator.generateOrder(), - channelConfig: mockGenerator.generateChannelConfig(), }; - const transformer = new TaxJarOrderCreatedPayloadTransformer(); + const providerConfig = mockGenerator.generateProviderConfig(); + const transformer = new TaxJarOrderCreatedPayloadTransformer(providerConfig); const transformedPayload = transformer.transform(payloadMock); expect(transformedPayload.params.amount).toBe(239.17); diff --git a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.ts b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.ts index a4ccf54..6810815 100644 --- a/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.ts +++ b/apps/taxes/src/modules/taxjar/order-created/taxjar-order-created-payload-transformer.ts @@ -1,8 +1,12 @@ import { LineItem } from "taxjar/dist/util/types"; import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql"; import { numbers } from "../../taxes/numbers"; -import { Payload, Target } from "./taxjar-order-created-adapter"; import { taxProviderUtils } from "../../taxes/tax-provider-utils"; +import { TaxJarConfig } from "../taxjar-connection-schema"; +import { + TaxJarOrderCreatedPayload, + TaxJarOrderCreatedTarget, +} from "./taxjar-order-created-adapter"; export function sumPayloadLines(lines: LineItem[]): number { return numbers.roundFloatToTwoDecimals( @@ -23,6 +27,7 @@ export function sumPayloadLines(lines: LineItem[]): number { } export class TaxJarOrderCreatedPayloadTransformer { + constructor(private readonly config: TaxJarConfig) {} private mapLines(lines: OrderCreatedSubscriptionFragment["lines"]): LineItem[] { return lines.map((line) => ({ quantity: line.quantity, @@ -35,7 +40,7 @@ export class TaxJarOrderCreatedPayloadTransformer { })); } - transform({ order, channelConfig }: Payload): Target { + transform({ order }: TaxJarOrderCreatedPayload): TaxJarOrderCreatedTarget { const lineItems = this.mapLines(order.lines); const lineSum = sumPayloadLines(lineItems); const shippingAmount = order.shippingPrice.gross.amount; @@ -47,11 +52,11 @@ export class TaxJarOrderCreatedPayloadTransformer { return { params: { - from_country: channelConfig.address.country, - from_zip: channelConfig.address.zip, - from_state: channelConfig.address.state, - from_city: channelConfig.address.city, - from_street: channelConfig.address.street, + from_country: this.config.address.country, + from_zip: this.config.address.zip, + from_state: this.config.address.state, + from_city: this.config.address.city, + from_street: this.config.address.street, to_country: order.shippingAddress!.country.code, to_zip: order.shippingAddress!.postalCode, to_state: order.shippingAddress!.countryArea, diff --git a/apps/taxes/src/modules/taxjar/taxjar-client.ts b/apps/taxes/src/modules/taxjar/taxjar-client.ts index 8537173..ab063cd 100644 --- a/apps/taxes/src/modules/taxjar/taxjar-client.ts +++ b/apps/taxes/src/modules/taxjar/taxjar-client.ts @@ -1,11 +1,11 @@ import TaxJar from "taxjar"; import { AddressParams, Config, CreateOrderParams, TaxParams } from "taxjar/dist/util/types"; import { createLogger, Logger } from "../../lib/logger"; -import { TaxJarConfig } from "./taxjar-config"; +import { TaxJarConfig } from "./taxjar-connection-schema"; const createTaxJarSettings = (config: TaxJarConfig): Config => { const settings: Config = { - apiKey: config.apiKey, + apiKey: config.credentials.apiKey, apiUrl: config.isSandbox ? TaxJar.SANDBOX_API_URL : TaxJar.DEFAULT_API_URL, }; @@ -29,44 +29,24 @@ export class TaxJarClient { private logger: Logger; constructor(providerConfig: TaxJarConfig) { - this.logger = createLogger({ service: "TaxJarClient" }); - this.logger.trace("TaxJarClient constructor"); + this.logger = createLogger({ location: "TaxJarClient" }); const settings = createTaxJarSettings(providerConfig); const taxJarClient = new TaxJar(settings); - this.logger.trace({ client: taxJarClient }, "External TaxJar client created"); this.client = taxJarClient; } async fetchTaxForOrder({ params }: FetchTaxForOrderArgs) { - this.logger.trace({ params }, "fetchTaxForOrder called with:"); const response = await this.client.taxForOrder(params); return response; } - async ping() { - this.logger.trace("ping called"); - try { - await this.client.categories(); - return { authenticated: true }; - } catch (error) { - return { - authenticated: false, - error: "TaxJar was not able to authenticate with the provided credentials.", - }; - } - } - async createOrder({ params }: CreateOrderArgs) { - this.logger.trace({ params }, "createOrder called with:"); - return this.client.createOrder(params); } async validateAddress({ params }: ValidateAddressArgs) { - this.logger.trace({ params }, "validateAddress called with:"); - return this.client.validateAddress(params); } } diff --git a/apps/taxes/src/modules/taxjar/taxjar-config-mock-generator.ts b/apps/taxes/src/modules/taxjar/taxjar-config-mock-generator.ts new file mode 100644 index 0000000..cea3ca6 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/taxjar-config-mock-generator.ts @@ -0,0 +1,30 @@ +import { TaxJarConfig } from "./taxjar-connection-schema"; + +export const defaultTaxJarConfig: TaxJarConfig = { + name: "", + isSandbox: false, + credentials: { + apiKey: "topSecretApiKey", + }, + address: { + city: "", + country: "", + state: "", + street: "", + zip: "", + }, +}; + +const testingScenariosMap = { + default: defaultTaxJarConfig, +}; + +export class TaxJarConfigMockGenerator { + constructor(private scenario: keyof typeof testingScenariosMap = "default") {} + + generateTaxJarConfig = (overrides: Partial = {}): TaxJarConfig => + structuredClone({ + ...testingScenariosMap[this.scenario], + ...overrides, + }); +} diff --git a/apps/taxes/src/modules/taxjar/taxjar-configuration.router.ts b/apps/taxes/src/modules/taxjar/taxjar-configuration.router.ts deleted file mode 100644 index 7f89c01..0000000 --- a/apps/taxes/src/modules/taxjar/taxjar-configuration.router.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { z } from "zod"; -import { createLogger } from "../../lib/logger"; -import { isObfuscated } from "../../lib/utils"; -import { protectedClientProcedure } from "../trpc/protected-client-procedure"; -import { router } from "../trpc/trpc-server"; -import { obfuscateTaxJarConfig, taxJarConfigSchema } from "./taxjar-config"; -import { TaxJarConfigurationService } from "./taxjar-configuration.service"; - -const getInputSchema = z.object({ - id: z.string(), -}); - -const deleteInputSchema = z.object({ - id: z.string(), -}); - -const patchInputSchema = z.object({ - id: z.string(), - value: taxJarConfigSchema.partial().transform((c) => { - const { apiKey, ...config } = c ?? {}; - - return { - ...config, - ...(apiKey && !isObfuscated(apiKey) && { apiKey }), - }; - }), -}); - -const postInputSchema = z.object({ - value: taxJarConfigSchema, -}); - -export const taxjarConfigurationRouter = router({ - get: protectedClientProcedure.input(getInputSchema).query(async ({ ctx, input }) => { - const logger = createLogger({ - saleorApiUrl: ctx.saleorApiUrl, - procedure: "taxjarConfigurationRouter.get", - }); - - logger.debug("taxjarConfigurationRouter.get called"); - - const { apiClient, saleorApiUrl } = ctx; - const taxjarConfigurationService = new TaxJarConfigurationService(apiClient, saleorApiUrl); - - const result = await taxjarConfigurationService.get(input.id); - - // * `providerInstance` name is required for secrets censorship - logger.debug({ providerInstance: result }, "taxjarConfigurationRouter.get finished"); - return { ...result, config: obfuscateTaxJarConfig(result.config) }; - }), - post: protectedClientProcedure.input(postInputSchema).mutation(async ({ ctx, input }) => { - const logger = createLogger({ - saleorApiUrl: ctx.saleorApiUrl, - procedure: "taxjarConfigurationRouter.post", - }); - - logger.debug("taxjarConfigurationRouter.post called"); - - const { apiClient, saleorApiUrl } = ctx; - const taxjarConfigurationService = new TaxJarConfigurationService(apiClient, saleorApiUrl); - - const result = await taxjarConfigurationService.post(input.value); - - logger.debug({ result }, "taxjarConfigurationRouter.post finished"); - - return result; - }), - delete: protectedClientProcedure.input(deleteInputSchema).mutation(async ({ ctx, input }) => { - const logger = createLogger({ - saleorApiUrl: ctx.saleorApiUrl, - procedure: "taxjarConfigurationRouter.delete", - }); - - logger.debug("taxjarConfigurationRouter.delete called"); - - const { apiClient, saleorApiUrl } = ctx; - const taxjarConfigurationService = new TaxJarConfigurationService(apiClient, saleorApiUrl); - - const result = await taxjarConfigurationService.delete(input.id); - - logger.debug({ result }, "taxjarConfigurationRouter.delete finished"); - - return result; - }), - patch: protectedClientProcedure.input(patchInputSchema).mutation(async ({ ctx, input }) => { - const logger = createLogger({ - saleorApiUrl: ctx.saleorApiUrl, - procedure: "taxjarConfigurationRouter.patch", - }); - - logger.debug("taxjarConfigurationRouter.patch called"); - - const { apiClient, saleorApiUrl } = ctx; - const taxjarConfigurationService = new TaxJarConfigurationService(apiClient, saleorApiUrl); - - const result = await taxjarConfigurationService.patch(input.id, input.value); - - logger.debug({ result }, "taxjarConfigurationRouter.patch finished"); - - return result; - }), -}); diff --git a/apps/taxes/src/modules/taxjar/taxjar-configuration.service.ts b/apps/taxes/src/modules/taxjar/taxjar-configuration.service.ts deleted file mode 100644 index 62ccb20..0000000 --- a/apps/taxes/src/modules/taxjar/taxjar-configuration.service.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Client } from "urql"; -import { createLogger, Logger } from "../../lib/logger"; -import { createSettingsManager } from "../app/metadata-manager"; -import { CrudSettingsManager } from "../crud-settings/crud-settings.service"; -import { providersSchema } from "../providers-configuration/providers-config"; -import { TAX_PROVIDER_KEY } from "../providers-configuration/public-providers-configuration-service"; -import { TaxJarClient } from "./taxjar-client"; -import { TaxJarConfig, TaxJarInstanceConfig, taxJarInstanceConfigSchema } from "./taxjar-config"; - -const getSchema = taxJarInstanceConfigSchema; - -export class TaxJarConfigurationService { - private crudSettingsManager: CrudSettingsManager; - private logger: Logger; - constructor(client: Client, saleorApiUrl: string) { - const settingsManager = createSettingsManager(client); - - this.crudSettingsManager = new CrudSettingsManager( - settingsManager, - saleorApiUrl, - TAX_PROVIDER_KEY - ); - this.logger = createLogger({ - service: "TaxJarConfigurationService", - metadataKey: TAX_PROVIDER_KEY, - }); - } - - async getAll(): Promise { - this.logger.debug(".getAll called"); - const { data } = await this.crudSettingsManager.readAll(); - - this.logger.debug(`Fetched settings from CrudSettingsManager`); - const validation = providersSchema.safeParse(data); - - if (!validation.success) { - this.logger.error({ error: validation.error.format() }, "Validation error while getAll"); - throw new Error(validation.error.message); - } - - const instances = validation.data.filter( - (instance) => instance.provider === "taxjar" - ) as TaxJarInstanceConfig[]; - - return instances; - } - - async get(id: string): Promise { - this.logger.debug(`.get called with id: ${id}`); - const { data } = await this.crudSettingsManager.read(id); - - this.logger.debug(`Fetched setting from CrudSettingsManager`); - - const validation = getSchema.safeParse(data); - - if (!validation.success) { - this.logger.error({ error: validation.error.format() }, "Validation error while get"); - throw new Error(validation.error.message); - } - - return validation.data; - } - - async post(config: TaxJarConfig): Promise<{ id: string }> { - this.logger.debug(`.post called with value: ${JSON.stringify(config)}`); - const taxJarClient = new TaxJarClient(config); - const validation = await taxJarClient.ping(); - - if (!validation.authenticated) { - this.logger.error({ error: validation.error }, "Validation error while post"); - throw new Error(validation.error); - } - const result = await this.crudSettingsManager.create({ - provider: "taxjar", - config: config, - }); - - return result.data; - } - - async patch(id: string, config: Partial): Promise { - this.logger.debug(`.patch called with id: ${id} and value: ${JSON.stringify(config)}`); - const data = await this.get(id); - // omit the key "id" from the result - const { id: _, ...setting } = data; - - return this.crudSettingsManager.update(id, { - ...setting, - config: { ...setting.config, ...config }, - }); - } - - async put(id: string, config: TaxJarConfig): Promise { - const data = await this.get(id); - // omit the key "id" from the result - const { id: _, ...setting } = data; - - this.logger.debug(`.put called with id: ${id} and value: ${JSON.stringify(config)}`); - return this.crudSettingsManager.update(id, { - ...setting, - config: { ...config }, - }); - } - - async delete(id: string): Promise { - this.logger.debug(`.delete called with id: ${id}`); - return this.crudSettingsManager.delete(id); - } -} diff --git a/apps/taxes/src/modules/taxjar/taxjar-config.ts b/apps/taxes/src/modules/taxjar/taxjar-connection-schema.ts similarity index 50% rename from apps/taxes/src/modules/taxjar/taxjar-config.ts rename to apps/taxes/src/modules/taxjar/taxjar-connection-schema.ts index b94d129..362e53f 100644 --- a/apps/taxes/src/modules/taxjar/taxjar-config.ts +++ b/apps/taxes/src/modules/taxjar/taxjar-connection-schema.ts @@ -1,34 +1,44 @@ import { z } from "zod"; -import { obfuscateSecret } from "../../lib/utils"; + +const addressSchema = z.object({ + country: z.string(), + zip: z.string(), + state: z.string(), + city: z.string(), + street: z.string(), +}); + +const taxJarCredentialsSchema = z.object({ + apiKey: z.string().min(1, { message: "API Key requires at least one character." }), +}); export const taxJarConfigSchema = z.object({ name: z.string().min(1, { message: "Name requires at least one character." }), - apiKey: z.string().min(1, { message: "API Key requires at least one character." }), isSandbox: z.boolean(), + credentials: taxJarCredentialsSchema, + address: addressSchema, }); export type TaxJarConfig = z.infer; export const defaultTaxJarConfig: TaxJarConfig = { name: "", - apiKey: "", isSandbox: false, + credentials: { + apiKey: "", + }, + address: { + city: "", + country: "", + state: "", + street: "", + zip: "", + }, }; -export const taxJarInstanceConfigSchema = z.object({ +export const taxJarConnection = z.object({ id: z.string(), provider: z.literal("taxjar"), config: taxJarConfigSchema, }); -export type TaxJarInstanceConfig = z.infer; - -export const obfuscateTaxJarConfig = (config: TaxJarConfig) => ({ - ...config, - apiKey: obfuscateSecret(config.apiKey), -}); - -export const obfuscateTaxJarInstances = (instances: TaxJarInstanceConfig[]) => - instances.map((instance) => ({ - ...instance, - config: obfuscateTaxJarConfig(instance.config), - })); +export type TaxJarConnection = z.infer; diff --git a/apps/taxes/src/modules/taxjar/taxjar-connection.router.ts b/apps/taxes/src/modules/taxjar/taxjar-connection.router.ts new file mode 100644 index 0000000..3f6510c --- /dev/null +++ b/apps/taxes/src/modules/taxjar/taxjar-connection.router.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; +import { createLogger } from "../../lib/logger"; +import { protectedClientProcedure } from "../trpc/protected-client-procedure"; +import { router } from "../trpc/trpc-server"; +import { taxJarConfigSchema } from "./taxjar-connection-schema"; +import { PublicTaxJarConnectionService } from "./configuration/public-taxjar-connection.service"; + +const getInputSchema = z.object({ + id: z.string(), +}); + +const deleteInputSchema = z.object({ + id: z.string(), +}); + +const patchInputSchema = z.object({ + id: z.string(), + value: taxJarConfigSchema.deepPartial(), +}); + +const postInputSchema = z.object({ + value: taxJarConfigSchema, +}); + +const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) => + next({ + ctx: { + ...ctx, + connectionService: new PublicTaxJarConnectionService( + ctx.apiClient, + ctx.appId!, + ctx.saleorApiUrl + ), + }, + }) +); + +export const taxjarConnectionRouter = router({ + getById: protectedWithConfigurationService.input(getInputSchema).query(async ({ ctx, input }) => { + const logger = createLogger({ + location: "taxjarConnectionRouter.get", + }); + + logger.debug("taxjarConnectionRouter.get called"); + + const result = await ctx.connectionService.getById(input.id); + + logger.info(`TaxJar configuration with an id: ${result.id} was successfully retrieved`); + + return result; + }), + create: protectedWithConfigurationService + .input(postInputSchema) + .mutation(async ({ ctx, input }) => { + const logger = createLogger({ + location: "taxjarConnectionRouter.post", + }); + + logger.debug("Attempting to create configuration"); + + const result = await ctx.connectionService.create(input.value); + + logger.info("TaxJar configuration was successfully created"); + + return result; + }), + delete: protectedWithConfigurationService + .input(deleteInputSchema) + .mutation(async ({ ctx, input }) => { + const logger = createLogger({ + location: "taxjarConnectionRouter.delete", + }); + + logger.debug("Route delete called"); + + const result = await ctx.connectionService.delete(input.id); + + logger.info(`TaxJar configuration with an id: ${input.id} was deleted`); + return result; + }), + update: protectedWithConfigurationService + .input(patchInputSchema) + .mutation(async ({ ctx, input }) => { + const logger = createLogger({ + location: "taxjarConnectionRouter.patch", + }); + + logger.debug({ input }, "Route patch called"); + + const result = await ctx.connectionService.update(input.id, input.value); + + logger.info(`TaxJar configuration with an id: ${input.id} was successfully updated`); + return result; + }), +}); diff --git a/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts b/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts index 55c8067..5c65e5d 100644 --- a/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts +++ b/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts @@ -1,16 +1,15 @@ import { OrderCreatedSubscriptionFragment, TaxBaseFragment } from "../../../generated/graphql"; import { Logger, createLogger } from "../../lib/logger"; -import { ChannelConfig } from "../channels-configuration/channels-config"; -import { ProviderWebhookService } from "../taxes/tax-provider-webhook"; import { TaxJarCalculateTaxesAdapter } from "./calculate-taxes/taxjar-calculate-taxes-adapter"; import { TaxJarClient } from "./taxjar-client"; -import { TaxJarConfig } from "./taxjar-config"; +import { TaxJarConfig } from "./taxjar-connection-schema"; import { TaxJarOrderCreatedAdapter } from "./order-created/taxjar-order-created-adapter"; +import { ProviderWebhookService } from "../taxes/tax-provider-webhook"; export class TaxJarWebhookService implements ProviderWebhookService { client: TaxJarClient; - config: TaxJarConfig; private logger: Logger; + private config: TaxJarConfig; constructor(config: TaxJarConfig) { const taxJarClient = new TaxJarClient(config); @@ -18,28 +17,23 @@ export class TaxJarWebhookService implements ProviderWebhookService { this.client = taxJarClient; this.config = config; this.logger = createLogger({ - service: "TaxJarWebhookService", + location: "TaxJarWebhookService", }); } - async calculateTaxes(taxBase: TaxBaseFragment, channelConfig: ChannelConfig) { - this.logger.debug({ taxBase, channelConfig }, "calculateTaxes called with:"); + async calculateTaxes(taxBase: TaxBaseFragment) { const adapter = new TaxJarCalculateTaxesAdapter(this.config); - const response = await adapter.send({ channelConfig, taxBase }); + const response = await adapter.send({ taxBase }); - this.logger.debug({ response }, "calculateTaxes response:"); return response; } - async createOrder(order: OrderCreatedSubscriptionFragment, channelConfig: ChannelConfig) { - this.logger.debug({ order, channelConfig }, "createOrder called with:"); - + async createOrder(order: OrderCreatedSubscriptionFragment) { const adapter = new TaxJarOrderCreatedAdapter(this.config); - const response = await adapter.send({ channelConfig, order }); + const response = await adapter.send({ order }); - this.logger.debug({ response }, "createOrder response:"); return response; } diff --git a/apps/taxes/src/modules/taxjar/ui/create-taxjar-configuration.tsx b/apps/taxes/src/modules/taxjar/ui/create-taxjar-configuration.tsx new file mode 100644 index 0000000..3dfca16 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/ui/create-taxjar-configuration.tsx @@ -0,0 +1,47 @@ +import { useDashboardNotification } from "@saleor/apps-shared"; +import { Button } from "@saleor/macaw-ui/next"; +import { useRouter } from "next/router"; +import { trpcClient } from "../../trpc/trpc-client"; +import { TaxJarConfig, defaultTaxJarConfig } from "../taxjar-connection-schema"; +import { TaxJarConfigurationForm } from "./taxjar-configuration-form"; +import React from "react"; + +export const CreateTaxJarConfiguration = () => { + const router = useRouter(); + const { notifySuccess, notifyError } = useDashboardNotification(); + + const { refetch: refetchProvidersConfigurationData } = + trpcClient.providersConfiguration.getAll.useQuery(); + + const { mutate: createMutation, isLoading: isCreateLoading } = + trpcClient.taxJarConnection.create.useMutation({ + async onSuccess() { + notifySuccess("Success", "Provider created"); + await refetchProvidersConfigurationData(); + router.push("/configuration"); + }, + onError(error) { + notifyError("Error", error.message); + }, + }); + + const submitHandler = React.useCallback( + (data: TaxJarConfig) => { + createMutation({ value: data }); + }, + [createMutation] + ); + + return ( + router.push("/configuration")} variant="tertiary"> + Cancel + + } + /> + ); +}; diff --git a/apps/taxes/src/modules/taxjar/ui/edit-taxjar-configuration.tsx b/apps/taxes/src/modules/taxjar/ui/edit-taxjar-configuration.tsx new file mode 100644 index 0000000..146f314 --- /dev/null +++ b/apps/taxes/src/modules/taxjar/ui/edit-taxjar-configuration.tsx @@ -0,0 +1,105 @@ +import { useDashboardNotification } from "@saleor/apps-shared"; +import { Box, Button, Text } from "@saleor/macaw-ui/next"; +import { useRouter } from "next/router"; +import React from "react"; +import { z } from "zod"; +import { Obfuscator } from "../../../lib/obfuscator"; +import { trpcClient } from "../../trpc/trpc-client"; +import { TaxJarConfig } from "../taxjar-connection-schema"; +import { TaxJarConfigurationForm } from "./taxjar-configuration-form"; +import { TaxJarConnectionObfuscator } from "../configuration/taxjar-connection-obfuscator"; + +const taxJarObfuscator = new TaxJarConnectionObfuscator(); + +export const EditTaxJarConfiguration = () => { + const router = useRouter(); + const { id } = router.query; + const configurationId = z.string().parse(id ?? ""); + + const { refetch: refetchProvidersConfigurationData } = + trpcClient.providersConfiguration.getAll.useQuery(); + + const { notifySuccess, notifyError } = useDashboardNotification(); + const { mutate: patchMutation, isLoading: isPatchLoading } = + trpcClient.taxJarConnection.update.useMutation({ + onSuccess() { + notifySuccess("Success", "Updated TaxJar configuration"); + refetchProvidersConfigurationData(); + }, + onError(error) { + notifyError("Error", error.message); + }, + }); + + const { + data, + isLoading: isGetLoading, + isError: isGetError, + } = trpcClient.taxJarConnection.getById.useQuery( + { id: configurationId }, + { + enabled: !!configurationId, + } + ); + + const submitHandler = React.useCallback( + (data: TaxJarConfig) => { + patchMutation({ + value: taxJarObfuscator.filterOutObfuscated(data), + id: configurationId, + }); + }, + [configurationId, patchMutation] + ); + + const { mutate: deleteMutation, isLoading: isDeleteLoading } = + trpcClient.taxJarConnection.delete.useMutation({ + onSuccess() { + notifySuccess("Success", "Deleted TaxJar configuration"); + refetchProvidersConfigurationData(); + router.push("/configuration"); + }, + onError(error) { + notifyError("Error", error.message); + }, + }); + + const deleteHandler = () => { + /* + * // todo: add support for window.confirm to AppBridge or wait on Dialog component in Macaw + * if (window.confirm("Are you sure you want to delete the provider?")) { + */ + deleteMutation({ id: configurationId }); + // } + }; + + if (isGetLoading) { + // todo: replace with skeleton once its available in Macaw + return ( + + Loading... + + ); + } + + if (isGetError) { + return ( + + Error while fetching the provider data. + + ); + } + + return ( + + Delete provider + + } + /> + ); +}; diff --git a/apps/taxes/src/modules/taxjar/ui/taxjar-configuration-form.tsx b/apps/taxes/src/modules/taxjar/ui/taxjar-configuration-form.tsx index b078d20..44aad3a 100644 --- a/apps/taxes/src/modules/taxjar/ui/taxjar-configuration-form.tsx +++ b/apps/taxes/src/modules/taxjar/ui/taxjar-configuration-form.tsx @@ -1,230 +1,150 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { - FormHelperText, - Grid, - InputLabel, - Switch, - TextField, - TextFieldProps, -} from "@material-ui/core"; -import { Delete, Save } from "@material-ui/icons"; -import { Button, makeStyles } from "@saleor/macaw-ui"; +import { TextLink } from "@saleor/apps-ui"; +import { Box, Button, Divider, Text } from "@saleor/macaw-ui/next"; +import { Input } from "@saleor/react-hook-form-macaw"; import React from "react"; -import { Controller, useForm } from "react-hook-form"; -import { z } from "zod"; -import { useInstanceId } from "../../taxes/tax-context"; -import { trpcClient } from "../../trpc/trpc-client"; -import { taxJarConfigSchema } from "../taxjar-config"; -import { useDashboardNotification } from "@saleor/apps-shared"; +import { useForm } from "react-hook-form"; +import { AppCard } from "../../ui/app-card"; +import { AppToggle } from "../../ui/app-toggle"; -const useStyles = makeStyles((theme) => ({ - reverseRow: { - display: "flex", - flexDirection: "row-reverse", - gap: theme.spacing(1), - }, -})); +import { CountrySelect } from "../../ui/country-select"; +import { TaxJarConfig, defaultTaxJarConfig, taxJarConfigSchema } from "../taxjar-connection-schema"; +import { ProviderLabel } from "../../ui/provider-label"; -const schema = taxJarConfigSchema; - -type FormValues = z.infer; - -const defaultValues: FormValues = { - name: "", - apiKey: "", - isSandbox: false, +const HelperText = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); }; -export const TaxJarConfigurationForm = () => { - const [isWarningDialogOpen, setIsWarningDialogOpen] = React.useState(false); - const styles = useStyles(); - const { instanceId, setInstanceId } = useInstanceId(); - const { handleSubmit, reset, control, formState } = useForm({ - resolver: zodResolver(schema), - defaultValues, +type TaxJarConfigurationFormProps = { + onSubmit: (data: TaxJarConfig) => void; + defaultValues: TaxJarConfig; + isLoading: boolean; + cancelButton: React.ReactNode; +}; + +export const TaxJarConfigurationForm = (props: TaxJarConfigurationFormProps) => { + const { handleSubmit, control, formState, reset } = useForm({ + defaultValues: defaultTaxJarConfig, + resolver: zodResolver(taxJarConfigSchema), }); - const { notifySuccess, notifyError } = useDashboardNotification(); - - const resetInstanceId = () => { - setInstanceId(null); - }; - - const { refetch: refetchChannelConfigurationData } = - trpcClient.channelsConfiguration.fetch.useQuery(undefined, { - onError(error) { - notifyError("Error", error.message); - }, - }); - - const { refetch: refetchProvidersConfigurationData } = - trpcClient.providersConfiguration.getAll.useQuery(); - const { data: instance } = trpcClient.taxJarConfiguration.get.useQuery( - { id: instanceId ?? "" }, - { - enabled: !!instanceId, - onError(error) { - notifyError("Error", error.message); - }, - } - ); - - const { mutate: createMutation, isLoading: isCreateLoading } = - trpcClient.taxJarConfiguration.post.useMutation({ - onSuccess({ id }) { - setInstanceId(id); - refetchProvidersConfigurationData(); - refetchChannelConfigurationData(); - - notifySuccess("Success", "Saved TaxJar configuration"); - }, - onError(error) { - notifyError("Error", error.message); - }, - }); - - const { mutate: updateMutation, isLoading: isUpdateLoading } = - trpcClient.taxJarConfiguration.patch.useMutation({ - onSuccess() { - refetchProvidersConfigurationData(); - refetchChannelConfigurationData(); - notifySuccess("Success", "Updated TaxJar configuration"); - }, - onError(error) { - notifyError("Error", error.message); - }, - }); - - const { mutate: deleteMutation, isLoading: isDeleteLoading } = - trpcClient.taxJarConfiguration.delete.useMutation({ - onSuccess() { - resetInstanceId(); - refetchProvidersConfigurationData(); - refetchChannelConfigurationData(); - - notifySuccess("Success", "Removed TaxJar instance"); - }, - onError(error) { - notifyError("Error", error.message); - }, - }); React.useEffect(() => { - if (instance) { - const { config } = instance; + reset(props.defaultValues); + }, [props.defaultValues, reset]); - reset(config); - } else { - reset({ ...defaultValues }); - } - }, [instance, instanceId, reset]); - - const textFieldProps: TextFieldProps = { - fullWidth: true, - }; - - const onSubmit = (value: FormValues) => { - if (instanceId) { - updateMutation({ - id: instanceId, - value, - }); - } else { - createMutation({ - value, - }); - } - }; - - const closeWarningDialog = () => { - setIsWarningDialogOpen(false); - }; - - const openWarningDialog = () => { - setIsWarningDialogOpen(true); - }; - - const deleteProvider = () => { - closeWarningDialog(); - if (instanceId) { - deleteMutation({ id: instanceId }); - } - }; - - const isLoading = isCreateLoading || isUpdateLoading; + const submitHandler = React.useCallback( + (data: TaxJarConfig) => { + props.onSubmit(data); + }, + [props] + ); return ( - <> -
    - - - + + + + + + + Unique identifier for your provider. + + + Credentials + + + + ( - - )} + name="credentials.apiKey" + required + label="API Key *" + helperText={formState.errors.credentials?.apiKey?.message} /> - {formState.errors.name && ( - {formState.errors.name.message} - )} - - - + You can obtain it by following the instructions from{" "} + + here + + . + + + + ( - - )} + label="Use sandbox mode" + helperText={ + + Toggling between{" "} + + Production and Sandbox + {" "} + environment. + + } + name="isSandbox" /> - {formState.errors?.apiKey && ( - {formState.errors?.apiKey.message} - )} - - - - Sandbox - ( - field.onChange(e.target.checked)} - /> - )} - /> - - - -
    -
    - - {instanceId && ( - - )} -
    +
    - {/* // todo: bring back to life once Dashboard allows to summon dialog */} - {/* */} - + ); }; diff --git a/apps/taxes/src/modules/taxjar/ui/taxjar-configuration.tsx b/apps/taxes/src/modules/taxjar/ui/taxjar-configuration.tsx deleted file mode 100644 index 09fd303..0000000 --- a/apps/taxes/src/modules/taxjar/ui/taxjar-configuration.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { TaxJarConfigurationForm } from "./taxjar-configuration-form"; - -export const TaxJarConfiguration = () => { - return ( -
    -

    TaxJar configuration

    - -
    - ); -}; diff --git a/apps/taxes/src/modules/taxjar/ui/taxjar-instructions.tsx b/apps/taxes/src/modules/taxjar/ui/taxjar-instructions.tsx new file mode 100644 index 0000000..abc399f --- /dev/null +++ b/apps/taxes/src/modules/taxjar/ui/taxjar-instructions.tsx @@ -0,0 +1,48 @@ +import { TextLink } from "@saleor/apps-ui"; +import { Box, Text } from "@saleor/macaw-ui/next"; +import { Section } from "../../ui/app-section"; + +export const TaxJarInstructions = () => { + return ( + + The form consists of two sections: Credentials and Address. +
    +
    + Credentials will fail if: + +
  • + - The API Key is incorrect. +
  • +
  • + - The API Key does not match "sandbox mode" setting. +
  • +
    +
    +
    + Address will fail if: + +
  • + + - The address does not match{" "} + + the desired format + + . + +
  • +
    +
    +
    + If the configuration fails, please visit the{" "} + + TaxJar documentation + + . + + } + /> + ); +}; diff --git a/apps/taxes/src/modules/trpc/trpc-app-router.ts b/apps/taxes/src/modules/trpc/trpc-app-router.ts index 81beb2d..3f1b250 100644 --- a/apps/taxes/src/modules/trpc/trpc-app-router.ts +++ b/apps/taxes/src/modules/trpc/trpc-app-router.ts @@ -1,16 +1,14 @@ -import { channelsRouter } from "../channels/channels.router"; import { router } from "./trpc-server"; -import { providersConfigurationRouter } from "../providers-configuration/providers-configuration.router"; -import { channelsConfigurationRouter } from "../channels-configuration/channels-configuration.router"; -import { taxjarConfigurationRouter } from "../taxjar/taxjar-configuration.router"; -import { avataxConfigurationRouter } from "../avatax/avatax-configuration.router"; +import { providerConnectionsRouter } from "../provider-connections/provider-connections.router"; +import { channelsConfigurationRouter } from "../channel-configuration/channel-configuration.router"; +import { taxjarConnectionRouter } from "../taxjar/taxjar-connection.router"; +import { avataxConnectionRouter } from "../avatax/avatax-connection.router"; export const appRouter = router({ - channels: channelsRouter, - providersConfiguration: providersConfigurationRouter, + providersConfiguration: providerConnectionsRouter, channelsConfiguration: channelsConfigurationRouter, - taxJarConfiguration: taxjarConfigurationRouter, - avataxConfiguration: avataxConfigurationRouter, + taxJarConnection: taxjarConnectionRouter, + avataxConnection: avataxConnectionRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/taxes/src/modules/ui/app-breadcrumbs.tsx b/apps/taxes/src/modules/ui/app-breadcrumbs.tsx new file mode 100644 index 0000000..8cdc7b3 --- /dev/null +++ b/apps/taxes/src/modules/ui/app-breadcrumbs.tsx @@ -0,0 +1,83 @@ +import { Breadcrumbs } from "@saleor/apps-ui"; +import { useRouter } from "next/router"; + +type Breadcrumb = { + label: string; + href?: string; +}; + +const newProviderBreadcrumbs: Breadcrumb[] = [ + { + href: "/configuration", + label: "Configuration", + }, + { + label: "Providers", + href: "/providers", + }, +]; + +const breadcrumbsForRoute: Record = { + "/": [], + "/configuration": [ + { + href: "/configuration", + label: "Configuration", + }, + ], + "/providers": [...newProviderBreadcrumbs], + "/providers/taxjar": [ + ...newProviderBreadcrumbs, + { + label: "TaxJar", + href: "/providers/taxjar", + }, + ], + + "/providers/taxjar/[id]": [ + ...newProviderBreadcrumbs, + { + label: "Editing TaxJar provider", + href: "/providers/taxjar", + }, + ], + "/providers/avatax": [ + ...newProviderBreadcrumbs, + { + label: "Avatax", + href: "/providers/avatax", + }, + ], + "/providers/avatax/[id]": [ + ...newProviderBreadcrumbs, + { + label: "Editing Avatax provider", + href: "/providers/avatax", + }, + ], +}; + +const useBreadcrumbs = () => { + const { pathname } = useRouter(); + const breadcrumbs = breadcrumbsForRoute[pathname]; + + if (pathname !== "/" && !breadcrumbs) { + throw new Error(`No breadcrumbs for route ${pathname}`); + } + + return breadcrumbs; +}; + +export const AppBreadcrumbs = () => { + const breadcrumbs = useBreadcrumbs(); + + return ( + + {breadcrumbs.map((breadcrumb) => ( + + {breadcrumb.label} + + ))} + + ); +}; diff --git a/apps/taxes/src/modules/ui/app-card.tsx b/apps/taxes/src/modules/ui/app-card.tsx new file mode 100644 index 0000000..04477eb --- /dev/null +++ b/apps/taxes/src/modules/ui/app-card.tsx @@ -0,0 +1,16 @@ +import { PropsWithBox, Box } from "@saleor/macaw-ui/next"; + +export const AppCard = ({ children, ...p }: PropsWithBox<{}>) => { + return ( + + {children} + + ); +}; diff --git a/apps/taxes/src/modules/ui/app-columns.tsx b/apps/taxes/src/modules/ui/app-columns.tsx new file mode 100644 index 0000000..877c0fc --- /dev/null +++ b/apps/taxes/src/modules/ui/app-columns.tsx @@ -0,0 +1,13 @@ +import { Box } from "@saleor/macaw-ui/next"; +import React, { PropsWithChildren } from "react"; + +export const AppColumns = ({ top, children }: PropsWithChildren<{ top: React.ReactNode }>) => { + return ( + + {top} + + {children} + + + ); +}; diff --git a/apps/taxes/src/modules/ui/app-container.tsx b/apps/taxes/src/modules/ui/app-container.tsx deleted file mode 100644 index ff29804..0000000 --- a/apps/taxes/src/modules/ui/app-container.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { makeStyles } from "@saleor/macaw-ui"; - -export const useStyles = makeStyles({ - root: { - maxWidth: 1180, - margin: "0 auto", - }, -}); - -export const AppContainer = ({ children }: { children: React.ReactNode }) => { - const styles = useStyles(); - - return
    {children}
    ; -}; diff --git a/apps/taxes/src/modules/ui/app-grid.tsx b/apps/taxes/src/modules/ui/app-grid.tsx deleted file mode 100644 index fd3efdd..0000000 --- a/apps/taxes/src/modules/ui/app-grid.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { makeStyles } from "@saleor/macaw-ui"; -import { PropsWithChildren } from "react"; - -export const useStyles = makeStyles({ - root: { - display: "grid", - gridTemplateColumns: "280px auto 280px", - alignItems: "start", - gap: 32, - }, -}); - -export type Props = PropsWithChildren<{}>; - -export const AppGrid = ({ children }: Props) => { - const styles = useStyles(); - - return
    {children}
    ; -}; diff --git a/apps/taxes/src/modules/ui/app-layout.tsx b/apps/taxes/src/modules/ui/app-layout.tsx index 9c618b2..7e83cc6 100644 --- a/apps/taxes/src/modules/ui/app-layout.tsx +++ b/apps/taxes/src/modules/ui/app-layout.tsx @@ -1,11 +1,12 @@ +import { Box } from "@saleor/macaw-ui/next"; import React from "react"; -import { AppContainer } from "./app-container"; -import { AppGrid } from "./app-grid"; +import { AppBreadcrumbs } from "./app-breadcrumbs"; export const AppLayout = ({ children }: { children: React.ReactNode }) => { return ( - - {children} - + + + {children} + ); }; diff --git a/apps/taxes/src/modules/ui/app-link.tsx b/apps/taxes/src/modules/ui/app-link.tsx deleted file mode 100644 index f42c101..0000000 --- a/apps/taxes/src/modules/ui/app-link.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Link } from "@material-ui/core"; -import { PropsWithChildren } from "react"; -import { useAppRedirect } from "../../lib/app/redirect"; - -export const AppLink = ({ children, href }: PropsWithChildren<{ href: string }>) => { - const { redirect } = useAppRedirect(); - - return ( - redirect(href)}> - {children} - - ); -}; diff --git a/apps/taxes/src/modules/ui/app-paper.tsx b/apps/taxes/src/modules/ui/app-paper.tsx deleted file mode 100644 index 6b58ca9..0000000 --- a/apps/taxes/src/modules/ui/app-paper.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Paper } from "@material-ui/core"; -import { makeStyles } from "@saleor/macaw-ui"; -import React from "react"; - -const useStyles = makeStyles({ - root: { - padding: "16px", - }, -}); - -export const AppPaper = ({ children }: { children: React.ReactNode }) => { - const styles = useStyles(); - - return ( - - {children} - - ); -}; diff --git a/apps/taxes/src/modules/ui/app-section.tsx b/apps/taxes/src/modules/ui/app-section.tsx new file mode 100644 index 0000000..2f5fe28 --- /dev/null +++ b/apps/taxes/src/modules/ui/app-section.tsx @@ -0,0 +1,38 @@ +import { Box, Text } from "@saleor/macaw-ui/next"; +import { PropsWithChildren } from "react"; + +const MAX_WIDTH = "480px"; + +const Header = ({ children }: PropsWithChildren) => { + return ( + + + {children} + + + ); +}; + +const Description = ({ + title, + description, +}: { + title: React.ReactNode; + description: React.ReactNode; +}) => { + return ( + + + {title} + + + {description} + + + ); +}; + +export const Section = { + Header, + Description, +}; diff --git a/apps/taxes/src/modules/ui/app-tab-nav-button.tsx b/apps/taxes/src/modules/ui/app-tab-nav-button.tsx deleted file mode 100644 index 8c5eff3..0000000 --- a/apps/taxes/src/modules/ui/app-tab-nav-button.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Link, makeStyles } from "@material-ui/core"; -import { PropsWithChildren } from "react"; -import { AppTab } from "../../pages/configuration"; -import { useActiveTab } from "../taxes/tax-context"; - -const useStyles = makeStyles((theme) => ({ - button: { - fontSize: "inherit", - fontFamily: "inherit", - verticalAlign: "unset", - }, -})); - -export const AppTabNavButton = ({ children, to }: PropsWithChildren<{ to: AppTab }>) => { - const styles = useStyles(); - const { setActiveTab } = useActiveTab(); - - return ( - setActiveTab(to)}> - {children} - - ); -}; diff --git a/apps/taxes/src/modules/ui/app-toggle.tsx b/apps/taxes/src/modules/ui/app-toggle.tsx new file mode 100644 index 0000000..a997d69 --- /dev/null +++ b/apps/taxes/src/modules/ui/app-toggle.tsx @@ -0,0 +1,30 @@ +import { Box, Text } from "@saleor/macaw-ui/next"; +import { Toggle, ToggleProps } from "@saleor/react-hook-form-macaw"; +import React from "react"; +import { Control, FieldPath, FieldValues } from "react-hook-form"; + +type AppToggleProps = Omit< + ToggleProps, + "children" +> & { + label: string; + helperText?: React.ReactNode; + name: FieldPath; + control: Control; +}; + +export const AppToggle = ({ + label, + helperText, + ...p +}: AppToggleProps) => { + return ( + + {/* without type="button", radix toggle value change triggers form submission */} + + {label} + + {helperText} + + ); +}; diff --git a/apps/taxes/src/modules/ui/countries.ts b/apps/taxes/src/modules/ui/countries.ts new file mode 100644 index 0000000..30eb431 --- /dev/null +++ b/apps/taxes/src/modules/ui/countries.ts @@ -0,0 +1,382 @@ +type CountryType = { + value: string; + label: string; +}; + +// From https://bitbucket.org/atlassian/atlaskit-mk-2/raw/4ad0e56649c3e6c973e226b7efaeb28cb240ccb0/packages/core/select/src/data/countries.js +export const countries: CountryType[] = [ + { value: "AD", label: "Andorra" }, + { + value: "AE", + label: "United Arab Emirates", + }, + { value: "AF", label: "Afghanistan" }, + { + value: "AG", + label: "Antigua and Barbuda", + }, + { value: "AI", label: "Anguilla" }, + { value: "AL", label: "Albania" }, + { value: "AM", label: "Armenia" }, + { value: "AO", label: "Angola" }, + { value: "AQ", label: "Antarctica" }, + { value: "AR", label: "Argentina" }, + { value: "AS", label: "American Samoa" }, + { value: "AT", label: "Austria" }, + { + value: "AU", + label: "Australia", + }, + { value: "AW", label: "Aruba" }, + { value: "AX", label: "Alland Islands" }, + { value: "AZ", label: "Azerbaijan" }, + { + value: "BA", + label: "Bosnia and Herzegovina", + }, + { value: "BB", label: "Barbados" }, + { value: "BD", label: "Bangladesh" }, + { value: "BE", label: "Belgium" }, + { value: "BF", label: "Burkina Faso" }, + { value: "BG", label: "Bulgaria" }, + { value: "BH", label: "Bahrain" }, + { value: "BI", label: "Burundi" }, + { value: "BJ", label: "Benin" }, + { value: "BL", label: "Saint Barthelemy" }, + { value: "BM", label: "Bermuda" }, + { value: "BN", label: "Brunei Darussalam" }, + { value: "BO", label: "Bolivia" }, + { value: "BR", label: "Brazil" }, + { value: "BS", label: "Bahamas" }, + { value: "BT", label: "Bhutan" }, + { value: "BV", label: "Bouvet Island" }, + { value: "BW", label: "Botswana" }, + { value: "BY", label: "Belarus" }, + { value: "BZ", label: "Belize" }, + { + value: "CA", + label: "Canada", + }, + { + value: "CC", + label: "Cocos (Keeling) Islands", + }, + { + value: "CD", + label: "Congo, Democratic Republic of the", + }, + { + value: "CF", + label: "Central African Republic", + }, + { + value: "CG", + label: "Congo, Republic of the", + }, + { value: "CH", label: "Switzerland" }, + { value: "CI", label: "Cote d'Ivoire" }, + { value: "CK", label: "Cook Islands" }, + { value: "CL", label: "Chile" }, + { value: "CM", label: "Cameroon" }, + { value: "CN", label: "China" }, + { value: "CO", label: "Colombia" }, + { value: "CR", label: "Costa Rica" }, + { value: "CU", label: "Cuba" }, + { value: "CV", label: "Cape Verde" }, + { value: "CW", label: "Curacao" }, + { value: "CX", label: "Christmas Island" }, + { value: "CY", label: "Cyprus" }, + { value: "CZ", label: "Czech Republic" }, + { + value: "DE", + label: "Germany", + }, + { value: "DJ", label: "Djibouti" }, + { value: "DK", label: "Denmark" }, + { value: "DM", label: "Dominica" }, + { + value: "DO", + label: "Dominican Republic", + }, + { value: "DZ", label: "Algeria" }, + { value: "EC", label: "Ecuador" }, + { value: "EE", label: "Estonia" }, + { value: "EG", label: "Egypt" }, + { value: "EH", label: "Western Sahara" }, + { value: "ER", label: "Eritrea" }, + { value: "ES", label: "Spain" }, + { value: "ET", label: "Ethiopia" }, + { value: "FI", label: "Finland" }, + { value: "FJ", label: "Fiji" }, + { + value: "FK", + label: "Falkland Islands (Malvinas)", + }, + { + value: "FM", + label: "Micronesia, Federated States of", + }, + { value: "FO", label: "Faroe Islands" }, + { + value: "FR", + label: "France", + }, + { value: "GA", label: "Gabon" }, + { value: "GB", label: "United Kingdom" }, + { value: "GD", label: "Grenada" }, + { value: "GE", label: "Georgia" }, + { value: "GF", label: "French Guiana" }, + { value: "GG", label: "Guernsey" }, + { value: "GH", label: "Ghana" }, + { value: "GI", label: "Gibraltar" }, + { value: "GL", label: "Greenland" }, + { value: "GM", label: "Gambia" }, + { value: "GN", label: "Guinea" }, + { value: "GP", label: "Guadeloupe" }, + { value: "GQ", label: "Equatorial Guinea" }, + { value: "GR", label: "Greece" }, + { + value: "GS", + label: "South Georgia and the South Sandwich Islands", + }, + { value: "GT", label: "Guatemala" }, + { value: "GU", label: "Guam" }, + { value: "GW", label: "Guinea-Bissau" }, + { value: "GY", label: "Guyana" }, + { value: "HK", label: "Hong Kong" }, + { + value: "HM", + label: "Heard Island and McDonald Islands", + }, + { value: "HN", label: "Honduras" }, + { value: "HR", label: "Croatia" }, + { value: "HT", label: "Haiti" }, + { value: "HU", label: "Hungary" }, + { value: "ID", label: "Indonesia" }, + { value: "IE", label: "Ireland" }, + { value: "IL", label: "Israel" }, + { value: "IM", label: "Isle of Man" }, + { value: "IN", label: "India" }, + { + value: "IO", + label: "British Indian Ocean Territory", + }, + { value: "IQ", label: "Iraq" }, + { + value: "IR", + label: "Iran, Islamic Republic of", + }, + { value: "IS", label: "Iceland" }, + { value: "IT", label: "Italy" }, + { value: "JE", label: "Jersey" }, + { value: "JM", label: "Jamaica" }, + { value: "JO", label: "Jordan" }, + { + value: "JP", + label: "Japan", + }, + { value: "KE", label: "Kenya" }, + { value: "KG", label: "Kyrgyzstan" }, + { value: "KH", label: "Cambodia" }, + { value: "KI", label: "Kiribati" }, + { value: "KM", label: "Comoros" }, + { + value: "KN", + label: "Saint Kitts and Nevis", + }, + { + value: "KP", + label: "Korea, Democratic People's Republic of", + }, + { value: "KR", label: "Korea, Republic of" }, + { value: "KW", label: "Kuwait" }, + { value: "KY", label: "Cayman Islands" }, + { value: "KZ", label: "Kazakhstan" }, + { + value: "LA", + label: "Lao People's Democratic Republic", + }, + { value: "LB", label: "Lebanon" }, + { value: "LC", label: "Saint Lucia" }, + { value: "LI", label: "Liechtenstein" }, + { value: "LK", label: "Sri Lanka" }, + { value: "LR", label: "Liberia" }, + { value: "LS", label: "Lesotho" }, + { value: "LT", label: "Lithuania" }, + { value: "LU", label: "Luxembourg" }, + { value: "LV", label: "Latvia" }, + { value: "LY", label: "Libya" }, + { value: "MA", label: "Morocco" }, + { value: "MC", label: "Monaco" }, + { + value: "MD", + label: "Moldova, Republic of", + }, + { value: "ME", label: "Montenegro" }, + { + value: "MF", + label: "Saint Martin (French part)", + }, + { value: "MG", label: "Madagascar" }, + { value: "MH", label: "Marshall Islands" }, + { + value: "MK", + label: "Macedonia, the Former Yugoslav Republic of", + }, + { value: "ML", label: "Mali" }, + { value: "MM", label: "Myanmar" }, + { value: "MN", label: "Mongolia" }, + { value: "MO", label: "Macao" }, + { + value: "MP", + label: "Northern Mariana Islands", + }, + { value: "MQ", label: "Martinique" }, + { value: "MR", label: "Mauritania" }, + { value: "MS", label: "Montserrat" }, + { value: "MT", label: "Malta" }, + { value: "MU", label: "Mauritius" }, + { value: "MV", label: "Maldives" }, + { value: "MW", label: "Malawi" }, + { value: "MX", label: "Mexico" }, + { value: "MY", label: "Malaysia" }, + { value: "MZ", label: "Mozambique" }, + { value: "NA", label: "Namibia" }, + { value: "NC", label: "New Caledonia" }, + { value: "NE", label: "Niger" }, + { value: "NF", label: "Norfolk Island" }, + { value: "NG", label: "Nigeria" }, + { value: "NI", label: "Nicaragua" }, + { value: "NL", label: "Netherlands" }, + { value: "NO", label: "Norway" }, + { value: "NP", label: "Nepal" }, + { value: "NR", label: "Nauru" }, + { value: "NU", label: "Niue" }, + { value: "NZ", label: "New Zealand" }, + { value: "OM", label: "Oman" }, + { value: "PA", label: "Panama" }, + { value: "PE", label: "Peru" }, + { value: "PF", label: "French Polynesia" }, + { value: "PG", label: "Papua New Guinea" }, + { value: "PH", label: "Philippines" }, + { value: "PK", label: "Pakistan" }, + { value: "PL", label: "Poland" }, + { + value: "PM", + label: "Saint Pierre and Miquelon", + }, + { value: "PN", label: "Pitcairn" }, + { value: "PR", label: "Puerto Rico" }, + { + value: "PS", + label: "Palestine, State of", + }, + { value: "PT", label: "Portugal" }, + { value: "PW", label: "Palau" }, + { value: "PY", label: "Paraguay" }, + { value: "QA", label: "Qatar" }, + { value: "RE", label: "Reunion" }, + { value: "RO", label: "Romania" }, + { value: "RS", label: "Serbia" }, + { value: "RU", label: "Russian Federation" }, + { value: "RW", label: "Rwanda" }, + { value: "SA", label: "Saudi Arabia" }, + { value: "SB", label: "Solomon Islands" }, + { value: "SC", label: "Seychelles" }, + { value: "SD", label: "Sudan" }, + { value: "SE", label: "Sweden" }, + { value: "SG", label: "Singapore" }, + { value: "SH", label: "Saint Helena" }, + { value: "SI", label: "Slovenia" }, + { + value: "SJ", + label: "Svalbard and Jan Mayen", + }, + { value: "SK", label: "Slovakia" }, + { value: "SL", label: "Sierra Leone" }, + { value: "SM", label: "San Marino" }, + { value: "SN", label: "Senegal" }, + { value: "SO", label: "Somalia" }, + { value: "SR", label: "Suriname" }, + { value: "SS", label: "South Sudan" }, + { + value: "ST", + label: "Sao Tome and Principe", + }, + { value: "SV", label: "El Salvador" }, + { + value: "SX", + label: "Sint Maarten (Dutch part)", + }, + { + value: "SY", + label: "Syrian Arab Republic", + }, + { value: "SZ", label: "Swaziland" }, + { + value: "TC", + label: "Turks and Caicos Islands", + }, + { value: "TD", label: "Chad" }, + { + value: "TF", + label: "French Southern Territories", + }, + { value: "TG", label: "Togo" }, + { value: "TH", label: "Thailand" }, + { value: "TJ", label: "Tajikistan" }, + { value: "TK", label: "Tokelau" }, + { value: "TL", label: "Timor-Leste" }, + { value: "TM", label: "Turkmenistan" }, + { value: "TN", label: "Tunisia" }, + { value: "TO", label: "Tonga" }, + { value: "TR", label: "Turkey" }, + { + value: "TT", + label: "Trinidad and Tobago", + }, + { value: "TV", label: "Tuvalu" }, + { + value: "TW", + label: "Taiwan, Province of China", + }, + { + value: "TZ", + label: "United Republic of Tanzania", + }, + { value: "UA", label: "Ukraine" }, + { value: "UG", label: "Uganda" }, + { + value: "US", + label: "United States", + }, + { value: "UY", label: "Uruguay" }, + { value: "UZ", label: "Uzbekistan" }, + { + value: "VA", + label: "Holy See (Vatican City State)", + }, + { + value: "VC", + label: "Saint Vincent and the Grenadines", + }, + { value: "VE", label: "Venezuela" }, + { + value: "VG", + label: "British Virgin Islands", + }, + { + value: "VI", + label: "US Virgin Islands", + }, + { value: "VN", label: "Vietnam" }, + { value: "VU", label: "Vanuatu" }, + { value: "WF", label: "Wallis and Futuna" }, + { value: "WS", label: "Samoa" }, + { value: "XK", label: "Kosovo" }, + { value: "YE", label: "Yemen" }, + { value: "YT", label: "Mayotte" }, + { value: "ZA", label: "South Africa" }, + { value: "ZM", label: "Zambia" }, + { value: "ZW", label: "Zimbabwe" }, +].sort((a, b) => (a.label > b.label ? 1 : -1)); diff --git a/apps/taxes/src/modules/ui/country-select.tsx b/apps/taxes/src/modules/ui/country-select.tsx new file mode 100644 index 0000000..e7818dc --- /dev/null +++ b/apps/taxes/src/modules/ui/country-select.tsx @@ -0,0 +1,19 @@ +import { Select, SelectProps } from "@saleor/react-hook-form-macaw"; +import { Control, FieldPath, FieldValues } from "react-hook-form"; +import { countries } from "./countries"; + +type CountrySelectProps = Omit< + SelectProps, + "options" +> & { + name: FieldPath; + control: Control; +}; + +export const CountrySelect = ({ + helperText, + value, + ...p +}: CountrySelectProps) => { + return