diff --git a/.changeset/nervous-toys-lay.md b/.changeset/nervous-toys-lay.md new file mode 100644 index 0000000..773020e --- /dev/null +++ b/.changeset/nervous-toys-lay.md @@ -0,0 +1,5 @@ +--- +"saleor-app-taxes": patch +--- + +Extracts the tax providers into individual services. Fixes the issue with updating configs with obfuscated values. diff --git a/apps/taxes/src/lib/utils.ts b/apps/taxes/src/lib/utils.ts index f66a0a8..70d8fac 100644 --- a/apps/taxes/src/lib/utils.ts +++ b/apps/taxes/src/lib/utils.ts @@ -3,3 +3,5 @@ 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/taxes/providers/avatax/avatax-calculate.ts b/apps/taxes/src/modules/avatax/avatax-calculate.ts similarity index 93% rename from apps/taxes/src/modules/taxes/providers/avatax/avatax-calculate.ts rename to apps/taxes/src/modules/avatax/avatax-calculate.ts index 7b520fe..4af420d 100644 --- a/apps/taxes/src/modules/taxes/providers/avatax/avatax-calculate.ts +++ b/apps/taxes/src/modules/avatax/avatax-calculate.ts @@ -1,11 +1,11 @@ import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel"; import { LineItemModel } from "avatax/lib/models/LineItemModel"; import { TransactionModel } from "avatax/lib/models/TransactionModel"; -import { TaxBaseFragment } from "../../../../../generated/graphql"; +import { TaxBaseFragment } from "../../../generated/graphql"; -import { ChannelConfig } from "../../../channels-configuration/channels-config"; -import { taxLineResolver } from "../../tax-line-resolver"; -import { ResponseTaxPayload } from "../../types"; +import { ChannelConfig } from "../channels-configuration/channels-config"; +import { taxLineResolver } from "../taxes/tax-line-resolver"; +import { ResponseTaxPayload } from "../taxes/types"; import { AvataxConfig } from "./avatax-config"; const SHIPPING_ITEM_CODE = "Shipping"; diff --git a/apps/taxes/src/modules/taxes/providers/avatax/avatax-client.ts b/apps/taxes/src/modules/avatax/avatax-client.ts similarity index 73% rename from apps/taxes/src/modules/taxes/providers/avatax/avatax-client.ts rename to apps/taxes/src/modules/avatax/avatax-client.ts index 2a74241..8157589 100644 --- a/apps/taxes/src/modules/taxes/providers/avatax/avatax-client.ts +++ b/apps/taxes/src/modules/avatax/avatax-client.ts @@ -1,7 +1,7 @@ import Avatax from "avatax"; import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel"; -import packageJson from "../../../../../package.json"; -import { logger } from "../../../../lib/logger"; +import packageJson from "../../../package.json"; +import { logger } from "../../lib/logger"; import { AvataxConfig } from "./avatax-config"; type AvataxSettings = { @@ -55,6 +55,20 @@ export class AvataxClient { } async ping() { - return this.client.ping(); + 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.", + }; + } } } diff --git a/apps/taxes/src/modules/avatax/avatax-config.ts b/apps/taxes/src/modules/avatax/avatax-config.ts new file mode 100644 index 0000000..377691f --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-config.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +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(), + companyName: z.string().min(1, { message: "Company name requires at least one character." }), + isAutocommit: z.boolean(), +}); + +export type AvataxConfig = z.infer; + +export const defaultAvataxConfig: AvataxConfig = { + name: "", + username: "", + password: "", + companyName: "", + isSandbox: true, + isAutocommit: false, +}; + +export const avataxInstanceConfigSchema = z.object({ + id: z.string(), + provider: z.literal("avatax"), + config: avataxConfigSchema, +}); + +export type AvataxInstanceConfig = z.infer; diff --git a/apps/taxes/src/modules/avatax/avatax-configuration.router.ts b/apps/taxes/src/modules/avatax/avatax-configuration.router.ts new file mode 100644 index 0000000..ae131a5 --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-configuration.router.ts @@ -0,0 +1,116 @@ +import { z } from "zod"; +import { logger as pinoLogger } from "../../lib/logger"; +import { protectedClientProcedure } from "../trpc/protected-client-procedure"; +import { router } from "../trpc/trpc-server"; +import { avataxConfigSchema } 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(), +}); + +const putInputSchema = z.object({ + id: z.string(), + value: avataxConfigSchema, +}); + +const postInputSchema = z.object({ + value: avataxConfigSchema, +}); + +export const avataxConfigurationRouter = router({ + get: protectedClientProcedure.input(getInputSchema).query(async ({ ctx, input }) => { + const logger = pinoLogger.child({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "avataxConfigurationRouter.get", + }); + + logger.debug("avataxConfigurationRouter.get called"); + + const { apiClient, saleorApiUrl } = ctx; + const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl); + + const result = await avataxConfigurationService.get(input.id); + + logger.debug({ result }, "avataxConfigurationRouter.get finished"); + + return result; + }), + post: protectedClientProcedure.input(postInputSchema).mutation(async ({ ctx, input }) => { + const logger = pinoLogger.child({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "avataxConfigurationRouter.post", + }); + + logger.debug("avataxConfigurationRouter.post called"); + + 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 = pinoLogger.child({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "avataxConfigurationRouter.delete", + }); + + logger.debug("avataxConfigurationRouter.delete called"); + + 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 = pinoLogger.child({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "avataxConfigurationRouter.patch", + }); + + logger.debug("avataxConfigurationRouter.patch called"); + + 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; + }), + put: protectedClientProcedure.input(putInputSchema).mutation(async ({ ctx, input }) => { + const logger = pinoLogger.child({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "avataxConfigurationRouter.put", + }); + + logger.debug("avataxConfigurationRouter.put called"); + + const { apiClient, saleorApiUrl } = ctx; + const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl); + + const result = await avataxConfigurationService.put(input.id, input.value); + + logger.debug({ result }, "avataxConfigurationRouter.put 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 new file mode 100644 index 0000000..4373f92 --- /dev/null +++ b/apps/taxes/src/modules/avatax/avatax-configuration.service.ts @@ -0,0 +1,151 @@ +import pino from "pino"; +import { Client } from "urql"; +import { createLogger } from "../../lib/logger"; +import { isObfuscated, obfuscateSecret } from "../../lib/utils"; +import { createSettingsManager } from "../app-configuration/metadata-manager"; +import { CrudSettingsConfigurator } from "../crud-settings/crud-settings.service"; +import { providersSchema } from "../providers-configuration/providers-config"; +import { TAX_PROVIDER_KEY } from "../providers-configuration/providers-configuration-service"; +import { AvataxClient } from "./avatax-client"; +import { + AvataxConfig, + avataxConfigSchema, + AvataxInstanceConfig, + avataxInstanceConfigSchema, +} from "./avatax-config"; + +const obfuscateConfig = (config: AvataxConfig) => ({ + ...config, + username: obfuscateSecret(config.username), + password: obfuscateSecret(config.password), +}); + +const obfuscateProvidersConfig = (instances: AvataxInstanceConfig[]) => + instances.map((instance) => ({ + ...instance, + config: obfuscateConfig(instance.config), + })); + +const getSchema = avataxInstanceConfigSchema.transform((instance) => ({ + ...instance, + config: obfuscateConfig(instance.config), +})); + +const patchSchema = avataxConfigSchema.partial().transform((c) => { + const { username, password, ...config } = c ?? {}; + return { + ...config, + ...(username && !isObfuscated(username) && { username }), + ...(password && !isObfuscated(password) && { password }), + }; +}); + +const putSchema = avataxConfigSchema.transform((c) => { + const { username, password, ...config } = c; + return { + ...config, + ...(!isObfuscated(username) && { username }), + ...(!isObfuscated(password) && { password }), + }; +}); + +export class AvataxConfigurationService { + private crudSettingsConfigurator: CrudSettingsConfigurator; + private logger: pino.Logger; + constructor(client: Client, saleorApiUrl: string) { + const settingsManager = createSettingsManager(client); + this.crudSettingsConfigurator = new CrudSettingsConfigurator( + settingsManager, + saleorApiUrl, + TAX_PROVIDER_KEY + ); + this.logger = createLogger({ + service: "AvataxConfigurationService", + metadataKey: TAX_PROVIDER_KEY, + }); + } + + async getAll() { + this.logger.debug(".getAll called"); + const { data } = await this.crudSettingsConfigurator.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 obfuscateProvidersConfig(instances); + } + + async get(id: string) { + this.logger.debug(`.get called with id: ${id}`); + const { data } = await this.crudSettingsConfigurator.read(id); + this.logger.debug({ setting: data }, `Fetched setting from crudSettingsConfigurator`); + + 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) { + 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); + } + } + + async patch(id: string, config: Partial) { + this.logger.debug(`.patch called with id: ${id} and value: ${JSON.stringify(config)}`); + const result = await this.get(id); + // omit the key "id" from the result + const { id: _, ...setting } = result; + const validation = patchSchema.safeParse(config); + + if (!validation.success) { + this.logger.error({ error: validation.error.format() }, "Validation error while patch"); + throw new Error(validation.error.message); + } + + return this.crudSettingsConfigurator.update(id, { + ...setting, + config: { ...setting.config, ...validation.data }, + }); + } + + async put(id: string, config: AvataxConfig) { + const result = await this.get(id); + // omit the key "id" from the result + const { id: _, ...setting } = result; + const validation = putSchema.safeParse(config); + + if (!validation.success) { + this.logger.error({ error: validation.error.format() }, "Validation error while patch"); + throw new Error(validation.error.message); + } + + this.logger.debug(`.put called with id: ${id} and value: ${JSON.stringify(config)}`); + return this.crudSettingsConfigurator.update(id, { + ...setting, + config: { ...validation.data }, + }); + } + + async delete(id: string) { + this.logger.debug(`.delete called with id: ${id}`); + return this.crudSettingsConfigurator.delete(id); + } +} diff --git a/apps/taxes/src/modules/taxes/providers/avatax/avatax-provider.ts b/apps/taxes/src/modules/avatax/avatax-provider.ts similarity index 60% rename from apps/taxes/src/modules/taxes/providers/avatax/avatax-provider.ts rename to apps/taxes/src/modules/avatax/avatax-provider.ts index 16c03c2..a312ad1 100644 --- a/apps/taxes/src/modules/taxes/providers/avatax/avatax-provider.ts +++ b/apps/taxes/src/modules/avatax/avatax-provider.ts @@ -1,7 +1,7 @@ -import { TaxBaseFragment } from "../../../../../generated/graphql"; -import { logger } from "../../../../lib/logger"; -import { ChannelConfig } from "../../../channels-configuration/channels-config"; -import { TaxProvider } from "../../tax-provider"; +import { TaxBaseFragment } from "../../../generated/graphql"; +import { logger } from "../../lib/logger"; +import { ChannelConfig } from "../channels-configuration/channels-config"; +import { TaxProvider } from "../taxes/tax-provider"; import { avataxCalculate } from "./avatax-calculate"; import { AvataxClient } from "./avatax-client"; import { AvataxConfig, defaultAvataxConfig } from "./avatax-config"; @@ -19,24 +19,6 @@ export class AvataxProvider implements TaxProvider { this.client = avataxClient; } - async validate() { - logger.info("Avatax validate"); - const validation = await this.client.ping(); - logger.info(validation, "Avatax ping result"); - - if (validation.authenticated) { - return { - ok: true, - }; - } - - return { - ok: false, - error: - "Avalara was unable to authenticate. Check if the username and password you provided are correct.", - }; - } - async calculate(payload: TaxBaseFragment, channel: ChannelConfig) { logger.info("Avatax calculate"); const model = avataxCalculate.preparePayload(payload, channel, this.config); diff --git a/apps/taxes/src/modules/taxes/providers/avatax/ui/avatax-configuration-form.tsx b/apps/taxes/src/modules/avatax/ui/avatax-configuration-form.tsx similarity index 76% rename from apps/taxes/src/modules/taxes/providers/avatax/ui/avatax-configuration-form.tsx rename to apps/taxes/src/modules/avatax/ui/avatax-configuration-form.tsx index 1d442bc..3831c11 100644 --- a/apps/taxes/src/modules/taxes/providers/avatax/ui/avatax-configuration-form.tsx +++ b/apps/taxes/src/modules/avatax/ui/avatax-configuration-form.tsx @@ -13,10 +13,10 @@ import { Button, makeStyles } from "@saleor/macaw-ui"; import React from "react"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; -import { trpcClient } from "../../../../trpc/trpc-client"; -import { AppLink } from "../../../../ui/app-link"; -import { useInstanceId } from "../../../tax-context"; -import { avataxInstanceConfigSchema } from "../avatax-config"; +import { trpcClient } from "../../trpc/trpc-client"; +import { AppLink } from "../../ui/app-link"; +import { useInstanceId } from "../../taxes/tax-context"; +import { avataxConfigSchema, avataxInstanceConfigSchema } from "../avatax-config"; const useStyles = makeStyles((theme) => ({ reverseRow: { @@ -26,17 +26,15 @@ const useStyles = makeStyles((theme) => ({ }, })); -const schema = avataxInstanceConfigSchema.omit({ provider: true }); +const schema = avataxConfigSchema; type FormValues = z.infer; const defaultValues: FormValues = { - config: { - companyName: "", - isAutocommit: false, - isSandbox: false, - password: "", - username: "", - }, + companyName: "", + isAutocommit: false, + isSandbox: false, + password: "", + username: "", name: "", }; @@ -50,11 +48,31 @@ export const AvataxConfigurationForm = () => { }); const { instanceId, setInstanceId } = useInstanceId(); const { refetch: refetchChannelConfigurationData } = - trpcClient.channelsConfiguration.fetch.useQuery(); - const { data: providersConfigurationData, refetch: refetchProvidersConfigurationData } = - trpcClient.providersConfiguration.getAll.useQuery(); + trpcClient.channelsConfiguration.fetch.useQuery(undefined, { + onError(error) { + appBridge?.dispatch( + actions.Notification({ + title: "Error", + text: error.message, + status: "error", + }) + ); + }, + }); + const { data: providersConfig, refetch: refetchProvidersConfigurationData } = + trpcClient.providersConfiguration.getAll.useQuery(undefined, { + onError(error) { + appBridge?.dispatch( + actions.Notification({ + title: "Error", + text: error.message, + status: "error", + }) + ); + }, + }); - const instance = providersConfigurationData?.find((instance) => instance.id === instanceId); + const instance = providersConfig?.find((instance) => instance.id === instanceId); const resetInstanceId = () => { setInstanceId(null); @@ -62,16 +80,16 @@ export const AvataxConfigurationForm = () => { React.useEffect(() => { if (instance) { - const { provider, id, ...values } = instance; - reset(values); + const { config } = instance; + reset(config); } else { reset(defaultValues); } }, [instance, reset]); const { mutate: createMutation, isLoading: isCreateLoading } = - trpcClient.providersConfiguration.create.useMutation({ - onSuccess({ id }) { + trpcClient.avataxConfiguration.post.useMutation({ + onSuccess({ data: { id } }) { setInstanceId(id); refetchProvidersConfigurationData(); appBridge?.dispatch( @@ -94,7 +112,7 @@ export const AvataxConfigurationForm = () => { }); const { mutate: updateMutation, isLoading: isUpdateLoading } = - trpcClient.providersConfiguration.update.useMutation({ + trpcClient.avataxConfiguration.patch.useMutation({ onSuccess() { refetchProvidersConfigurationData(); appBridge?.dispatch( @@ -116,7 +134,7 @@ export const AvataxConfigurationForm = () => { }, }); - const { mutate: deleteMutation } = trpcClient.providersConfiguration.delete.useMutation({ + const { mutate: deleteMutation } = trpcClient.avataxConfiguration.delete.useMutation({ onSuccess() { resetInstanceId(); refetchProvidersConfigurationData(); @@ -144,21 +162,15 @@ export const AvataxConfigurationForm = () => { fullWidth: true, }; - const onSubmit = (values: FormValues) => { + const onSubmit = (value: FormValues) => { if (instanceId) { updateMutation({ id: instanceId, - provider: { - ...values, - provider: "avatax", - }, + value, }); } else { createMutation({ - provider: { - ...values, - provider: "avatax", - }, + value, }); } }; @@ -201,9 +213,9 @@ export const AvataxConfigurationForm = () => { Sandbox ( { Autocommit ( { ( )} /> - {formState.errors.config?.username && ( - {formState.errors.config.username.message} + {formState.errors.username && ( + {formState.errors.username.message} )} } /> - {formState.errors.config?.password && ( - {formState.errors.config.password.message} + {formState.errors.password && ( + {formState.errors.password.message} )} ( )} /> - {formState.errors.config?.companyName && ( - {formState.errors.config.companyName.message} + {formState.errors.companyName && ( + {formState.errors.companyName.message} )} diff --git a/apps/taxes/src/modules/taxes/providers/avatax/ui/avatax-configuration.tsx b/apps/taxes/src/modules/avatax/ui/avatax-configuration.tsx similarity index 84% rename from apps/taxes/src/modules/taxes/providers/avatax/ui/avatax-configuration.tsx rename to apps/taxes/src/modules/avatax/ui/avatax-configuration.tsx index 9e01933..f4467dc 100644 --- a/apps/taxes/src/modules/taxes/providers/avatax/ui/avatax-configuration.tsx +++ b/apps/taxes/src/modules/avatax/ui/avatax-configuration.tsx @@ -3,7 +3,7 @@ import { AvataxConfigurationForm } from "./avatax-configuration-form"; export const AvataxConfiguration = () => { return (
-

Avalara configuration

+

Avatax configuration

); diff --git a/apps/taxes/src/modules/channels/ui/channel-tax-provider-form.tsx b/apps/taxes/src/modules/channels/ui/channel-tax-provider-form.tsx index 2a147c7..8fa81dd 100644 --- a/apps/taxes/src/modules/channels/ui/channel-tax-provider-form.tsx +++ b/apps/taxes/src/modules/channels/ui/channel-tax-provider-form.tsx @@ -78,9 +78,32 @@ export const ChannelTaxProviderForm = () => { const { channelSlug } = useChannelSlug(); const { data: channelConfigurationData, refetch: refetchChannelConfigurationData } = - trpcClient.channelsConfiguration.fetch.useQuery(); + trpcClient.channelsConfiguration.fetch.useQuery(undefined, { + onError(error) { + appBridge?.dispatch( + actions.Notification({ + title: "Error", + text: error.message, + status: "error", + }) + ); + }, + }); - const { data: providerInstances = [] } = trpcClient.providersConfiguration.getAll.useQuery(); + const { data: providerInstances = [] } = trpcClient.providersConfiguration.getAll.useQuery( + undefined, + { + onError(error) { + appBridge?.dispatch( + actions.Notification({ + title: "Error", + text: error.message, + status: "error", + }) + ); + }, + } + ); const channelConfig = channelConfigurationData?.[channelSlug]; const { mutate, isLoading } = trpcClient.channelsConfiguration.upsert.useMutation({ @@ -136,10 +159,10 @@ export const ChannelTaxProviderForm = () => { defaultValue={""} render={({ field }) => (