diff --git a/.changeset/many-parrots-sleep.md b/.changeset/many-parrots-sleep.md new file mode 100644 index 0000000..4247413 --- /dev/null +++ b/.changeset/many-parrots-sleep.md @@ -0,0 +1,5 @@ +--- +"saleor-app-taxes": minor +--- + +Changed the avatax configuration flow. Previously, the configuration was validated when trying to create it. Now, you have to verify the credentials and address manually by clicking the "verify" buttons. If the address was resolved successfully, formatting suggestions provided by Avatax address resolution service may be displayed. The user can apply or reject the suggestions. If the address was not resolved correctly, an error will be thrown. diff --git a/apps/taxes/src/lib/error-utils.ts b/apps/taxes/src/lib/error-utils.ts new file mode 100644 index 0000000..de3ad56 --- /dev/null +++ b/apps/taxes/src/lib/error-utils.ts @@ -0,0 +1,13 @@ +import { TRPCClientError } from "@trpc/client"; + +function resolveTrpcClientError(error: unknown) { + if (error instanceof TRPCClientError) { + return error.message; + } + + return "Unknown error"; +} + +export const errorUtils = { + resolveTrpcClientError, +}; diff --git a/apps/taxes/src/modules/avatax/avatax-client.ts b/apps/taxes/src/modules/avatax/avatax-client.ts index 4911c65..90f291c 100644 --- a/apps/taxes/src/modules/avatax/avatax-client.ts +++ b/apps/taxes/src/modules/avatax/avatax-client.ts @@ -1,12 +1,12 @@ import Avatax from "avatax"; +import { DocumentType } from "avatax/lib/enums/DocumentType"; +import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo"; +import { CommitTransactionModel } from "avatax/lib/models/CommitTransactionModel"; import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel"; import packageJson from "../../../package.json"; import { createLogger, Logger } from "../../lib/logger"; -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"; import { AvataxClientTaxCodeService } from "./avatax-client-tax-code.service"; +import { BaseAvataxConfig } from "./avatax-connection-schema"; type AvataxSettings = { appName: string; @@ -29,10 +29,10 @@ const defaultAvataxSettings: AvataxSettings = { timeout: 5000, }; -const createAvataxSettings = (config: AvataxConfig): AvataxSettings => { +const createAvataxSettings = ({ isSandbox }: { isSandbox: boolean }): AvataxSettings => { const settings: AvataxSettings = { ...defaultAvataxSettings, - environment: config.isSandbox ? "sandbox" : "production", + environment: isSandbox ? "sandbox" : "production", }; return settings; @@ -57,10 +57,10 @@ export class AvataxClient { private client: Avatax; private logger: Logger; - constructor(config: AvataxConfig) { + constructor(baseConfig: BaseAvataxConfig) { this.logger = createLogger({ name: "AvataxClient" }); - const settings = createAvataxSettings(config); - const avataxClient = new Avatax(settings).withSecurity(config.credentials); + const settings = createAvataxSettings({ isSandbox: baseConfig.isSandbox }); + const avataxClient = new Avatax(settings).withSecurity(baseConfig.credentials); this.client = avataxClient; } @@ -82,4 +82,8 @@ export class AvataxClient { return taxCodeService.getTaxCodes(); } + + async ping() { + return this.client.ping(); + } } diff --git a/apps/taxes/src/modules/avatax/avatax-connection-schema.ts b/apps/taxes/src/modules/avatax/avatax-connection-schema.ts index 0662c6e..3a09240 100644 --- a/apps/taxes/src/modules/avatax/avatax-connection-schema.ts +++ b/apps/taxes/src/modules/avatax/avatax-connection-schema.ts @@ -13,16 +13,24 @@ const avataxCredentialsSchema = z.object({ 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." }), +// All that is needed to create Avatax configuration. +export const baseAvataxConfigSchema = z.object({ isSandbox: z.boolean(), - companyCode: z.string().optional(), - isAutocommit: z.boolean(), - shippingTaxCode: z.string().optional(), credentials: avataxCredentialsSchema, - address: addressSchema, }); +export type BaseAvataxConfig = z.infer; + +export const avataxConfigSchema = z + .object({ + name: z.string().min(1, { message: "Name requires at least one character." }), + companyCode: z.string().optional(), + isAutocommit: z.boolean(), + shippingTaxCode: z.string().optional(), + address: addressSchema, + }) + .merge(baseAvataxConfigSchema); + export type AvataxConfig = z.infer; export const defaultAvataxConfig: AvataxConfig = { diff --git a/apps/taxes/src/modules/avatax/avatax-connection.router.ts b/apps/taxes/src/modules/avatax/avatax-connection.router.ts index 9d6c511..79c5986 100644 --- a/apps/taxes/src/modules/avatax/avatax-connection.router.ts +++ b/apps/taxes/src/modules/avatax/avatax-connection.router.ts @@ -2,7 +2,12 @@ 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 { AvataxClient } from "./avatax-client"; +import { avataxConfigSchema, baseAvataxConfigSchema } from "./avatax-connection-schema"; +import { AvataxAddressValidationService } from "./configuration/avatax-address-validation.service"; +import { AvataxAuthValidationService } from "./configuration/avatax-auth-validation.service"; +import { AvataxEditAddressValidationService } from "./configuration/avatax-edit-address-validation.service"; +import { AvataxEditAuthValidationService } from "./configuration/avatax-edit-auth-validation.service"; import { PublicAvataxConnectionService } from "./configuration/public-avatax-connection.service"; const getInputSchema = z.object({ @@ -22,20 +27,20 @@ const postInputSchema = z.object({ value: avataxConfigSchema, }); -const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) => +const protectedWithConnectionService = protectedClientProcedure.use(({ next, ctx }) => next({ ctx: { - connectionService: new PublicAvataxConnectionService( - ctx.apiClient, - ctx.appId!, - ctx.saleorApiUrl - ), + connectionService: new PublicAvataxConnectionService({ + appId: ctx.appId!, + client: ctx.apiClient, + saleorApiUrl: ctx.saleorApiUrl, + }), }, }) ); export const avataxConnectionRouter = router({ - verifyConnections: protectedWithConfigurationService.query(async ({ ctx }) => { + verifyConnections: protectedWithConnectionService.query(async ({ ctx }) => { const logger = createLogger({ name: "avataxConnectionRouter.verifyConnections", }); @@ -48,7 +53,7 @@ export const avataxConnectionRouter = router({ return { ok: true }; }), - getById: protectedWithConfigurationService.input(getInputSchema).query(async ({ ctx, input }) => { + getById: protectedWithConnectionService.input(getInputSchema).query(async ({ ctx, input }) => { const logger = createLogger({ name: "avataxConnectionRouter.get", }); @@ -61,23 +66,21 @@ export const avataxConnectionRouter = router({ return result; }), - create: protectedWithConfigurationService - .input(postInputSchema) - .mutation(async ({ ctx, input }) => { - const logger = createLogger({ - saleorApiUrl: ctx.saleorApiUrl, - procedure: "avataxConnectionRouter.post", - }); + create: protectedWithConnectionService.input(postInputSchema).mutation(async ({ ctx, input }) => { + const logger = createLogger({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "avataxConnectionRouter.post", + }); - logger.debug("Attempting to create configuration"); + logger.debug("Attempting to create configuration"); - const result = await ctx.connectionService.create(input.value); + const result = await ctx.connectionService.create(input.value); - logger.info("Avatax configuration was successfully created"); + logger.info("Avatax configuration was successfully created"); - return result; - }), - delete: protectedWithConfigurationService + return result; + }), + delete: protectedWithConnectionService .input(deleteInputSchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ @@ -93,7 +96,7 @@ export const avataxConnectionRouter = router({ return result; }), - update: protectedWithConfigurationService + update: protectedWithConnectionService .input(patchInputSchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ @@ -107,6 +110,98 @@ export const avataxConnectionRouter = router({ logger.info(`Avatax configuration with an id: ${input.id} was successfully updated`); + return result; + }), + /* + * There are separate methods for address validation for edit and create + * because some form values in the edit form can be obfuscated. + * When calling the "editValidateAddress", we are checking if the credentials + * are obfuscated. If they are, we use the stored credentials and mix them with + * unobfuscated values from the form. + */ + editValidateAddress: protectedClientProcedure + .input(z.object({ value: avataxConfigSchema, id: z.string() })) + .mutation(async ({ ctx, input }) => { + const logger = createLogger({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "avataxConnectionRouter.editValidateAddress", + }); + + logger.debug("Route called"); + + const addressValidationService = new AvataxEditAddressValidationService({ + appId: ctx.appId!, + client: ctx.apiClient, + saleorApiUrl: ctx.saleorApiUrl, + }); + + const result = await addressValidationService.validate(input.id, input.value); + + logger.info(`Avatax address was successfully validated`); + + return result; + }), + createValidateAddress: protectedWithConnectionService + .input(postInputSchema) + .mutation(async ({ ctx, input }) => { + const logger = createLogger({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "avataxConnectionRouter.createValidateAddress", + }); + + logger.debug("Route called"); + + const avataxClient = new AvataxClient(input.value); + + const addressValidation = new AvataxAddressValidationService(avataxClient); + + const result = await addressValidation.validate(input.value.address); + + logger.info(`Avatax address was successfully validated`); + + return result; + }), + /* + * There are separate methods for credentials validation for edit and create + * because some form values in the edit form can be obfuscated. + * When calling the "editValidateCredentials", we are checking if the credentials + * are obfuscated. If they are, we use the stored credentials and mix them with + * unobfuscated values from the form. + */ + editValidateCredentials: protectedClientProcedure + .input(z.object({ value: baseAvataxConfigSchema, id: z.string() })) + .mutation(async ({ ctx, input }) => { + const logger = createLogger({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "avataxConnectionRouter.validateAuth", + }); + + const authValidation = new AvataxEditAuthValidationService({ + appId: ctx.appId!, + client: ctx.apiClient, + saleorApiUrl: ctx.saleorApiUrl, + }); + + await authValidation.validate(input.id, input.value); + + logger.info(`Avatax client was successfully validated`); + }), + createValidateCredentials: protectedClientProcedure + .input(z.object({ value: baseAvataxConfigSchema })) + .mutation(async ({ ctx, input }) => { + const logger = createLogger({ + saleorApiUrl: ctx.saleorApiUrl, + procedure: "avataxConnectionRouter.createValidateAuth", + }); + + const avataxClient = new AvataxClient(input.value); + + const authValidation = new AvataxAuthValidationService(avataxClient); + + const result = await authValidation.validate(); + + logger.info(`Avatax client was successfully validated`); + return result; }), }); diff --git a/apps/taxes/src/modules/avatax/avatax-config-obfuscator.test.ts b/apps/taxes/src/modules/avatax/avatax-obfuscator.test.ts similarity index 89% rename from apps/taxes/src/modules/avatax/avatax-config-obfuscator.test.ts rename to apps/taxes/src/modules/avatax/avatax-obfuscator.test.ts index 4715f00..ee11265 100644 --- a/apps/taxes/src/modules/avatax/avatax-config-obfuscator.test.ts +++ b/apps/taxes/src/modules/avatax/avatax-obfuscator.test.ts @@ -1,11 +1,11 @@ import { AvataxConfigMockGenerator } from "./avatax-config-mock-generator"; -import { AvataxConnectionObfuscator } from "./avatax-connection-obfuscator"; +import { AvataxObfuscator } from "./avatax-obfuscator"; import { expect, it, describe } from "vitest"; const mockAvataxConfig = new AvataxConfigMockGenerator().generateAvataxConfig(); -const obfuscator = new AvataxConnectionObfuscator(); +const obfuscator = new AvataxObfuscator(); -describe("AvataxConnectionObfuscator", () => { +describe("AvataxObfuscator", () => { it("obfuscated avatax config", () => { const obfuscatedConfig = obfuscator.obfuscateAvataxConfig(mockAvataxConfig); diff --git a/apps/taxes/src/modules/avatax/avatax-connection-obfuscator.ts b/apps/taxes/src/modules/avatax/avatax-obfuscator.ts similarity index 97% rename from apps/taxes/src/modules/avatax/avatax-connection-obfuscator.ts rename to apps/taxes/src/modules/avatax/avatax-obfuscator.ts index 739542c..80a9052 100644 --- a/apps/taxes/src/modules/avatax/avatax-connection-obfuscator.ts +++ b/apps/taxes/src/modules/avatax/avatax-obfuscator.ts @@ -1,7 +1,7 @@ import { Obfuscator } from "../../lib/obfuscator"; import { AvataxConfig, AvataxConnection } from "./avatax-connection-schema"; -export class AvataxConnectionObfuscator { +export class AvataxObfuscator { private obfuscator = new Obfuscator(); obfuscateAvataxConfig = (config: AvataxConfig): AvataxConfig => { diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-address-validation.service.ts b/apps/taxes/src/modules/avatax/configuration/avatax-address-validation.service.ts new file mode 100644 index 0000000..5643412 --- /dev/null +++ b/apps/taxes/src/modules/avatax/configuration/avatax-address-validation.service.ts @@ -0,0 +1,27 @@ +import { createLogger, Logger } from "../../../lib/logger"; +import { avataxAddressFactory } from "../address-factory"; +import { AvataxClient } from "../avatax-client"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxValidationErrorResolver } from "./avatax-validation-error-resolver"; + +export class AvataxAddressValidationService { + private logger: Logger; + + constructor(private avataxClient: AvataxClient) { + this.logger = createLogger({ + name: "AvataxAddressValidationService", + }); + } + + async validate(address: AvataxConfig["address"]) { + const formattedAddress = avataxAddressFactory.fromChannelAddress(address); + + try { + return this.avataxClient.validateAddress({ address: formattedAddress }); + } catch (error) { + const errorResolver = new AvataxValidationErrorResolver(); + + throw errorResolver.resolve(error); + } + } +} diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-auth-validation.service.ts b/apps/taxes/src/modules/avatax/configuration/avatax-auth-validation.service.ts new file mode 100644 index 0000000..08c959c --- /dev/null +++ b/apps/taxes/src/modules/avatax/configuration/avatax-auth-validation.service.ts @@ -0,0 +1,27 @@ +import { createLogger, Logger } from "../../../lib/logger"; +import { AvataxClient } from "../avatax-client"; +import { AvataxValidationErrorResolver } from "./avatax-validation-error-resolver"; + +export class AvataxAuthValidationService { + private logger: Logger; + + constructor(private avataxClient: AvataxClient) { + this.logger = createLogger({ + name: "AvataxAuthValidationService", + }); + } + + async validate() { + try { + const result = await this.avataxClient.ping(); + + if (!result.authenticated) { + throw new Error("Invalid Avatax credentials."); + } + } catch (error) { + const errorResolver = new AvataxValidationErrorResolver(); + + throw errorResolver.resolve(error); + } + } +} diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-connection.service.ts b/apps/taxes/src/modules/avatax/configuration/avatax-connection.service.ts index 6d31983..bbccbde 100644 --- a/apps/taxes/src/modules/avatax/configuration/avatax-connection.service.ts +++ b/apps/taxes/src/modules/avatax/configuration/avatax-connection.service.ts @@ -4,12 +4,22 @@ import { Logger, createLogger } from "../../../lib/logger"; import { createSettingsManager } from "../../app/metadata-manager"; import { AvataxConfig, AvataxConnection } from "../avatax-connection-schema"; import { AvataxConnectionRepository } from "./avatax-connection-repository"; -import { AvataxValidationService } from "./avatax-validation.service"; +import { AvataxAuthValidationService } from "./avatax-auth-validation.service"; +import { AvataxClient } from "../avatax-client"; export class AvataxConnectionService { private logger: Logger; private avataxConnectionRepository: AvataxConnectionRepository; - constructor(client: Client, appId: string, saleorApiUrl: string) { + + constructor({ + client, + appId, + saleorApiUrl, + }: { + client: Client; + appId: string; + saleorApiUrl: string; + }) { this.logger = createLogger({ name: "AvataxConnectionService", }); @@ -19,6 +29,13 @@ export class AvataxConnectionService { this.avataxConnectionRepository = new AvataxConnectionRepository(settingsManager, saleorApiUrl); } + private async checkIfAuthorized(input: AvataxConfig) { + const avataxClient = new AvataxClient(input); + const authValidationService = new AvataxAuthValidationService(avataxClient); + + await authValidationService.validate(); + } + getAll(): Promise { return this.avataxConnectionRepository.getAll(); } @@ -27,12 +44,10 @@ export class AvataxConnectionService { return this.avataxConnectionRepository.get(id); } - async create(config: AvataxConfig): Promise<{ id: string }> { - const validationService = new AvataxValidationService(); + async create(input: AvataxConfig): Promise<{ id: string }> { + await this.checkIfAuthorized(input); - await validationService.validate(config); - - return await this.avataxConnectionRepository.post(config); + return this.avataxConnectionRepository.post(input); } async update(id: string, nextConfigPartial: DeepPartial): Promise { @@ -41,8 +56,6 @@ export class AvataxConnectionService { const { id: _, ...setting } = data; const prevConfig = setting.config; - const validationService = new AvataxValidationService(); - // todo: add deepRightMerge const input: AvataxConfig = { ...prevConfig, @@ -57,7 +70,7 @@ export class AvataxConnectionService { }, }; - await validationService.validate(input); + await this.checkIfAuthorized(input); return this.avataxConnectionRepository.patch(id, { config: input }); } diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-edit-address-validation.service.ts b/apps/taxes/src/modules/avatax/configuration/avatax-edit-address-validation.service.ts new file mode 100644 index 0000000..2f4e052 --- /dev/null +++ b/apps/taxes/src/modules/avatax/configuration/avatax-edit-address-validation.service.ts @@ -0,0 +1,40 @@ +import { Client } from "urql"; +import { AvataxClient } from "../avatax-client"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxAddressValidationService } from "./avatax-address-validation.service"; +import { AvataxPatchInputTransformer } from "./avatax-patch-input-transformer"; + +export class AvataxEditAddressValidationService { + private client: Client; + private appId: string; + private saleorApiUrl: string; + + constructor({ + client, + appId, + saleorApiUrl, + }: { + client: Client; + appId: string; + saleorApiUrl: string; + }) { + this.client = client; + this.appId = appId; + this.saleorApiUrl = saleorApiUrl; + } + + async validate(id: string, input: AvataxConfig) { + const transformer = new AvataxPatchInputTransformer({ + client: this.client, + appId: this.appId, + saleorApiUrl: this.saleorApiUrl, + }); + + const config = await transformer.patchInput(id, input); + + const avataxClient = new AvataxClient(config); + const addressValidation = new AvataxAddressValidationService(avataxClient); + + return addressValidation.validate(input.address); + } +} diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-edit-auth-validation.service.ts b/apps/taxes/src/modules/avatax/configuration/avatax-edit-auth-validation.service.ts new file mode 100644 index 0000000..4727df9 --- /dev/null +++ b/apps/taxes/src/modules/avatax/configuration/avatax-edit-auth-validation.service.ts @@ -0,0 +1,44 @@ +import { Client } from "urql"; +import { createLogger, Logger } from "../../../lib/logger"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxAuthValidationService } from "./avatax-auth-validation.service"; +import { AvataxPatchInputTransformer } from "./avatax-patch-input-transformer"; +import { AvataxClient } from "../avatax-client"; + +export class AvataxEditAuthValidationService { + private logger: Logger; + private client: Client; + private appId: string; + private saleorApiUrl: string; + + constructor({ + client, + appId, + saleorApiUrl, + }: { + client: Client; + appId: string; + saleorApiUrl: string; + }) { + this.client = client; + this.appId = appId; + this.saleorApiUrl = saleorApiUrl; + this.logger = createLogger({ + name: "AvataxAuthValidationService", + }); + } + + async validate(id: string, input: Pick) { + const transformer = new AvataxPatchInputTransformer({ + client: this.client, + appId: this.appId, + saleorApiUrl: this.saleorApiUrl, + }); + const credentials = await transformer.patchCredentials(id, input.credentials); + const avataxClient = new AvataxClient({ ...input, credentials }); + + const authValidationService = new AvataxAuthValidationService(avataxClient); + + return authValidationService.validate(); + } +} diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-patch-input-transformer.ts b/apps/taxes/src/modules/avatax/configuration/avatax-patch-input-transformer.ts new file mode 100644 index 0000000..eec0d89 --- /dev/null +++ b/apps/taxes/src/modules/avatax/configuration/avatax-patch-input-transformer.ts @@ -0,0 +1,68 @@ +import { Client } from "urql"; +import { Logger, createLogger } from "../../../lib/logger"; +import { Obfuscator } from "../../../lib/obfuscator"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxConnectionService } from "./avatax-connection.service"; + +/* + * This class is used to merge the given input with the existing configuration. + * The input from the edit UI is obfuscated, so we need to filter out those fields, and merge it with the existing configuration. + */ +export class AvataxPatchInputTransformer { + private logger: Logger; + private connection: AvataxConnectionService; + private obfuscator: Obfuscator; + + constructor({ + client, + appId, + saleorApiUrl, + }: { + client: Client; + appId: string; + saleorApiUrl: string; + }) { + this.logger = createLogger({ + name: "AvataxPatchInputTransformer", + }); + this.connection = new AvataxConnectionService({ client, appId, saleorApiUrl }); + this.obfuscator = new Obfuscator(); + } + + private checkIfNotObfuscated(config: AvataxConfig) { + return ( + !this.obfuscator.isObfuscated(config.credentials.password) && + !this.obfuscator.isObfuscated(config.credentials.username) + ); + } + + async patchCredentials(id: string, input: AvataxConfig["credentials"]) { + const connection = await this.connection.getById(id); + + const credentials: AvataxConfig["credentials"] = { + ...connection.config.credentials, + username: this.obfuscator.isObfuscated(input.username) + ? connection.config.credentials.username + : input.username, + password: this.obfuscator.isObfuscated(input.password) + ? connection.config.credentials.password + : input.password, + }; + + return credentials; + } + + async patchInput(id: string, input: AvataxConfig) { + // We can use the input straight away if it's not obfuscated. + if (this.checkIfNotObfuscated(input)) { + return input; + } + + const mergedConfig: AvataxConfig = { + ...input, + credentials: await this.patchCredentials(id, input.credentials), + }; + + return mergedConfig; + } +} diff --git a/apps/taxes/src/modules/avatax/configuration/avatax-validation.service.ts b/apps/taxes/src/modules/avatax/configuration/avatax-validation.service.ts deleted file mode 100644 index 582fe3d..0000000 --- a/apps/taxes/src/modules/avatax/configuration/avatax-validation.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -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({ - name: "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 index b9504ee..e461748 100644 --- a/apps/taxes/src/modules/avatax/configuration/public-avatax-connection.service.ts +++ b/apps/taxes/src/modules/avatax/configuration/public-avatax-connection.service.ts @@ -1,15 +1,23 @@ import { DeepPartial } from "@trpc/server"; import { Client } from "urql"; -import { AvataxConnectionObfuscator } from "../avatax-connection-obfuscator"; import { AvataxConfig } from "../avatax-connection-schema"; +import { AvataxObfuscator } from "../avatax-obfuscator"; 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(); + private readonly obfuscator: AvataxObfuscator; + constructor({ + client, + appId, + saleorApiUrl, + }: { + client: Client; + appId: string; + saleorApiUrl: string; + }) { + this.connectionService = new AvataxConnectionService({ client, appId, saleorApiUrl }); + this.obfuscator = new AvataxObfuscator(); } async getAll() { diff --git a/apps/taxes/src/modules/avatax/tax-code/avatax-tax-codes.router.ts b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-codes.router.ts index a720acf..dcfe06a 100644 --- a/apps/taxes/src/modules/avatax/tax-code/avatax-tax-codes.router.ts +++ b/apps/taxes/src/modules/avatax/tax-code/avatax-tax-codes.router.ts @@ -14,11 +14,11 @@ export const avataxTaxCodesRouter = router({ name: "avataxTaxCodesRouter.getAllForId", }); - const connectionService = new AvataxConnectionService( - ctx.apiClient, - ctx.appId!, - ctx.saleorApiUrl - ); + const connectionService = new AvataxConnectionService({ + appId: ctx.appId!, + client: ctx.apiClient, + saleorApiUrl: ctx.saleorApiUrl, + }); const connection = await connectionService.getById(input.connectionId); const taxCodesService = new AvataxTaxCodesService(connection.config); diff --git a/apps/taxes/src/modules/avatax/ui/avatax-address-resolution-processor.test.ts b/apps/taxes/src/modules/avatax/ui/avatax-address-resolution-processor.test.ts new file mode 100644 index 0000000..7783aa3 --- /dev/null +++ b/apps/taxes/src/modules/avatax/ui/avatax-address-resolution-processor.test.ts @@ -0,0 +1,134 @@ +import { AddressResolutionModel } from "avatax/lib/models/AddressResolutionModel"; +import { AvataxAddressResolutionProcessor } from "./avatax-address-resolution-processor"; +import { describe, it, expect } from "vitest"; +import { ResolutionQuality } from "avatax/lib/enums/ResolutionQuality"; +import { JurisdictionType } from "avatax/lib/enums/JurisdictionType"; + +const mockedSuccessResponse: AddressResolutionModel = { + address: { + line1: "20 COOPER ST", + city: "NEW YORK", + region: "NY", + country: "US", + postalCode: "10034", + }, + validatedAddresses: [ + { + addressType: "UnknownAddressType", + line1: "20 COOPER ST", + line2: "", + line3: "", + city: "NEW YORK", + region: "NY", + country: "US", + postalCode: "10034", + latitude: 40.86758, + longitude: -73.924502, + }, + ], + coordinates: { + latitude: 40.86758, + longitude: -73.924502, + }, + resolutionQuality: ResolutionQuality.Intersection, + taxAuthorities: [ + { + avalaraId: "42", + jurisdictionName: "NEW YORK", + jurisdictionType: JurisdictionType.State, + signatureCode: "BFEQ", + }, + { + avalaraId: "359071", + jurisdictionName: "METROPOLITAN COMMUTER TRANSPORTATION DISTRICT", + jurisdictionType: JurisdictionType.Special, + signatureCode: "BGJP", + }, + { + avalaraId: "2001053475", + jurisdictionName: "NEW YORK CITY", + jurisdictionType: JurisdictionType.City, + signatureCode: "EHTG", + }, + ], +}; + +const mockedUnresolvedResponse: AddressResolutionModel = { + address: { + line1: "42069 COOPER ST ", + city: "NEW YORK", + region: "NY", + country: "US", + postalCode: "10034", + }, + validatedAddresses: [ + { + addressType: "UnknownAddressType", + line1: "42069 COOPER ST", + line2: "", + line3: "", + city: "NEW YORK", + region: "NY", + country: "US", + postalCode: "10034", + latitude: 40.866728, + longitude: -73.921445, + }, + ], + coordinates: { + latitude: 40.866728, + longitude: -73.921445, + }, + resolutionQuality: ResolutionQuality.Intersection, + messages: [ + { + summary: "The address number is out of range", + details: + "The address was found but the street number in the input address was not between the low and high range of the post office database.", + refersTo: "Address.Line0", + severity: "Error", + source: "Avalara.AvaTax.Common", + }, + ], +}; + +const processor = new AvataxAddressResolutionProcessor(); + +describe("AvataxAddressResolutionProcessor", () => { + describe("resolveAddressResolutionMessage", () => { + it("returns success when no messages", () => { + const result = processor.resolveAddressResolutionMessage(mockedSuccessResponse); + + expect(result).toEqual({ + type: "success", + message: "The address was resolved successfully.", + }); + }); + it("returns info when messages", () => { + const result = processor.resolveAddressResolutionMessage(mockedUnresolvedResponse); + + expect(result).toEqual({ + type: "info", + message: "The address number is out of range", + }); + }); + }); + describe("extractSuggestionsFromResponse", () => { + it("throws an error when no address", () => { + expect(() => processor.extractSuggestionsFromResponse({} as any)).toThrowError( + "No address found" + ); + }); + it("returns suggestions when address", () => { + const result = processor.extractSuggestionsFromResponse(mockedSuccessResponse); + + expect(result).toEqual({ + street: "20 COOPER ST", + city: "NEW YORK", + state: "NY", + country: "US", + zip: "10034", + }); + }); + }); +}); diff --git a/apps/taxes/src/modules/avatax/ui/avatax-address-resolution-processor.ts b/apps/taxes/src/modules/avatax/ui/avatax-address-resolution-processor.ts new file mode 100644 index 0000000..41c96e2 --- /dev/null +++ b/apps/taxes/src/modules/avatax/ui/avatax-address-resolution-processor.ts @@ -0,0 +1,43 @@ +import { AddressResolutionModel } from "avatax/lib/models/AddressResolutionModel"; +import { AddressSuggestions } from "./avatax-configuration-address-fragment"; + +export class AvataxAddressResolutionProcessor { + extractSuggestionsFromResponse(response: AddressResolutionModel): AddressSuggestions { + const address = response.validatedAddresses?.[0]; + + if (!address) { + throw new Error("No address found"); + } + + const lines = [address.line1, address.line2, address.line3].filter(Boolean); + const street = lines.join(" "); + + return { + street, + city: address.city ?? "", + state: address.region ?? "", + country: address.country ?? "", + zip: address.postalCode ?? "", + }; + } + + resolveAddressResolutionMessage(response: AddressResolutionModel): { + type: "success" | "info"; + message: string; + } { + if (!response.messages) { + // When address was resolved completely, it has no messages. + return { + type: "success", + message: "The address was resolved successfully.", + }; + } + + const message = response.messages?.[0]; + + return { + type: "info", + message: message?.summary ?? "The address was not resolved completely.", + }; + } +} diff --git a/apps/taxes/src/modules/avatax/ui/avatax-configuration-address-fragment.tsx b/apps/taxes/src/modules/avatax/ui/avatax-configuration-address-fragment.tsx new file mode 100644 index 0000000..ed1d968 --- /dev/null +++ b/apps/taxes/src/modules/avatax/ui/avatax-configuration-address-fragment.tsx @@ -0,0 +1,192 @@ +import { useDashboardNotification } from "@saleor/apps-shared"; +import { Box, Button, Text } from "@saleor/macaw-ui/next"; +import { Input } from "@saleor/react-hook-form-macaw"; +import { AddressResolutionModel } from "avatax/lib/models/AddressResolutionModel"; +import React from "react"; +import { useFormContext } from "react-hook-form"; +import { errorUtils } from "../../../lib/error-utils"; +import { CountrySelect } from "../../ui/country-select"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { useAvataxConfigurationStatus } from "./configuration-status"; +import { FormSection } from "./form-section"; +import { AvataxAddressResolutionProcessor } from "./avatax-address-resolution-processor"; + +const FieldSuggestion = ({ suggestion }: { suggestion: string }) => { + return ( + + + {suggestion} + + + ); +}; + +export type AddressSuggestions = AvataxConfig["address"]; + +const avataxAddressResolutionProcessor = new AvataxAddressResolutionProcessor(); + +type AvataxConfigurationAddressFragmentProps = { + onValidateAddress: (address: AvataxConfig) => Promise; + isLoading: boolean; +}; + +export const AvataxConfigurationAddressFragment = ( + props: AvataxConfigurationAddressFragmentProps +) => { + const { control, formState, getValues, setValue, watch } = useFormContext(); + const { status, setStatus } = useAvataxConfigurationStatus(); + const [suggestions, setSuggestions] = React.useState(); + + const { notifyError, notifySuccess, notifyInfo } = useDashboardNotification(); + + React.useEffect(() => { + const subscription = watch((_, { name, type }) => { + // Force user to reverify when address is changed + if ( + /* + * Type is undefined when the fields change is programmatic, so in "applyClickHandler". We don't want to + * reverify in this case. + */ + type !== undefined && + (name === "address.city" || + name === "address.country" || + name === "address.state" || + name === "address.street" || + name === "address.zip") + ) { + setSuggestions(undefined); + setStatus("authenticated"); + } + }); + + return () => subscription.unsubscribe(); + }, [setStatus, watch]); + + const verifyClickHandler = React.useCallback(async () => { + const config = getValues(); + + try { + const result = await props.onValidateAddress(config); + + const { type, message } = + avataxAddressResolutionProcessor.resolveAddressResolutionMessage(result); + + if (type === "info") { + notifyInfo("Address verified", message); + } + + if (type === "success") { + notifySuccess("Address verified", message); + } + + setSuggestions(avataxAddressResolutionProcessor.extractSuggestionsFromResponse(result)); + setStatus("address_valid"); + } catch (e) { + setStatus("address_invalid"); + notifyError("Invalid address", errorUtils.resolveTrpcClientError(e)); + } + }, [getValues, notifyError, notifyInfo, notifySuccess, props, setStatus]); + + const applyClickHandler = React.useCallback(() => { + setStatus("address_verified"); + setSuggestions(undefined); + setValue("address", { + city: suggestions?.city ?? "", + country: suggestions?.country ?? "", + state: suggestions?.state ?? "", + street: suggestions?.street ?? "", + zip: suggestions?.zip ?? "", + }); + }, [setStatus, setValue, suggestions]); + + const rejectClickHandler = React.useCallback(() => { + setStatus("address_verified"); + setSuggestions(undefined); + }, [setStatus]); + + const disabled = status === "not_authenticated"; + + return ( + <> + + + + {suggestions?.street && } + + + + {suggestions?.city && } + + + + {suggestions?.state && } + + + + + + {suggestions?.zip && } + + + + {status !== "address_valid" && ( + + + + )} + {suggestions && ( + + + + + + + )} + + + ); +}; diff --git a/apps/taxes/src/modules/avatax/ui/avatax-configuration-credentials-fragment.tsx b/apps/taxes/src/modules/avatax/ui/avatax-configuration-credentials-fragment.tsx new file mode 100644 index 0000000..a1793f1 --- /dev/null +++ b/apps/taxes/src/modules/avatax/ui/avatax-configuration-credentials-fragment.tsx @@ -0,0 +1,150 @@ +import { useDashboardNotification } from "@saleor/apps-shared"; +import { TextLink } from "@saleor/apps-ui"; +import { Box, Button } from "@saleor/macaw-ui/next"; +import { Input } from "@saleor/react-hook-form-macaw"; +import React from "react"; +import { useFormContext } from "react-hook-form"; +import { errorUtils } from "../../../lib/error-utils"; +import { AppToggle } from "../../ui/app-toggle"; +import { AvataxConfig, BaseAvataxConfig } from "../avatax-connection-schema"; +import { useAvataxConfigurationStatus } from "./configuration-status"; +import { HelperText } from "./form-helper-text"; +import { FormSection } from "./form-section"; + +type AvataxConfigurationCredentialsFragmentProps = { + onValidateCredentials: (input: BaseAvataxConfig) => Promise; + isLoading: boolean; +}; + +export const AvataxConfigurationCredentialsFragment = ( + props: AvataxConfigurationCredentialsFragmentProps +) => { + const { control, formState, getValues, watch } = useFormContext(); + const { setStatus } = useAvataxConfigurationStatus(); + + const { notifyError, notifySuccess } = useDashboardNotification(); + + React.useEffect(() => { + const subscription = watch((_, { name }) => { + // Force user to reverify when credentials are changed + if (name === "credentials.password" || name === "credentials.username") { + setStatus("not_authenticated"); + } + }); + + return () => subscription.unsubscribe(); + }, [setStatus, watch]); + + const verifyCredentials = React.useCallback(async () => { + const value = getValues(); + + try { + await props.onValidateCredentials({ + credentials: value.credentials, + isSandbox: value.isSandbox, + }); + notifySuccess("Credentials verified"); + setStatus("authenticated"); + } catch (e) { + notifyError("Invalid credentials", errorUtils.resolveTrpcClientError(e)); + + setStatus("not_authenticated"); + } + }, [getValues, notifyError, notifySuccess, props, setStatus]); + + return ( + <> + + +
+ + + You can obtain it in the API Keys section of SettingsLicense{" "} + in your Avalara Dashboard. + +
+
+ + + You can obtain it in the API Keys section of SettingsLicense{" "} + in your Avalara Dashboard. + +
+ +
+ + + When not provided, the default company will be used.{" "} + + Read more + {" "} + about company codes. + +
+
+ + + Toggling between{" "} + + Production and Sandbox + {" "} + environment. + + } + name="isSandbox" + /> + + + If enabled, the order will be automatically{" "} + + commited to Avalara. + {" "} + + } + name="isAutocommit" + /> + +
+ + + + + ); +}; 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 e0f6fef..b52e0dc 100644 --- a/apps/taxes/src/modules/avatax/ui/avatax-configuration-form.tsx +++ b/apps/taxes/src/modules/avatax/ui/avatax-configuration-form.tsx @@ -1,43 +1,56 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { TextLink } from "@saleor/apps-ui"; -import { Box, Button, Divider, Text } from "@saleor/macaw-ui/next"; +import { Box, Button, Divider } from "@saleor/macaw-ui/next"; import { Input } from "@saleor/react-hook-form-macaw"; +import { AddressResolutionModel } from "avatax/lib/models/AddressResolutionModel"; import React from "react"; -import { useForm } from "react-hook-form"; +import { FormProvider, 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 HelperText = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ); -}; +import { + AvataxConfig, + BaseAvataxConfig, + avataxConfigSchema, + defaultAvataxConfig, +} from "../avatax-connection-schema"; +import { AvataxConfigurationAddressFragment } from "./avatax-configuration-address-fragment"; +import { AvataxConfigurationCredentialsFragment } from "./avatax-configuration-credentials-fragment"; +import { AvataxConfigurationTaxesFragment } from "./avatax-configuration-taxes-fragment"; +import { HelperText } from "./form-helper-text"; +import { useAvataxConfigurationStatus } from "./configuration-status"; type AvataxConfigurationFormProps = { - onSubmit: (data: AvataxConfig) => void; + submit: { + handleFn: (data: AvataxConfig) => void; + isLoading: boolean; + }; + validateAddress: { + handleFn: (config: AvataxConfig) => Promise; + isLoading: boolean; + }; + validateCredentials: { + handleFn: (config: BaseAvataxConfig) => Promise; + isLoading: boolean; + }; defaultValues: AvataxConfig; - isLoading: boolean; leftButton: React.ReactNode; }; export const AvataxConfigurationForm = (props: AvataxConfigurationFormProps) => { - const { handleSubmit, control, formState, reset } = useForm({ + const { status } = useAvataxConfigurationStatus(); + const formMethods = useForm({ defaultValues: defaultAvataxConfig, resolver: zodResolver(avataxConfigSchema), }); + const { handleSubmit, control, formState, reset } = formMethods; + React.useEffect(() => { reset(props.defaultValues); }, [props.defaultValues, reset]); const submitHandler = React.useCallback( (data: AvataxConfig) => { - props.onSubmit(data); + props.submit.handleFn(data); }, [props] ); @@ -47,182 +60,44 @@ export const AvataxConfigurationForm = (props: AvataxConfigurationFormProps) => + +
+ + Unique identifier for your provider. + + + + + + + - - - Unique identifier for your provider. - - - Credentials - - - -
- - - You can obtain it in the API Keys section of SettingsLicense{" "} - in your Avalara Dashboard. - -
-
- - - You can obtain it in the API Keys section of SettingsLicense{" "} - in your Avalara Dashboard. - -
+ + {props.leftButton} -
- - - When not provided, the default company will be used.{" "} - - Read more - {" "} - about company codes. - -
+
- - - Toggling between{" "} - - Production and Sandbox - {" "} - environment. - - } - name="isSandbox" - /> - - - If enabled, the order will be automatically{" "} - - commited to Avalara. - {" "} - - } - name="isAutocommit" - /> - -
- - - Address - - - - - - - - - - - Tax codes - - -
- - - Tax code that for the shipping line sent to Avatax.{" "} - - Must match Avatax tax codes format. - - -
-
- - - - {props.leftButton} - - - - + +
); }; diff --git a/apps/taxes/src/modules/avatax/ui/avatax-configuration-taxes-fragment.tsx b/apps/taxes/src/modules/avatax/ui/avatax-configuration-taxes-fragment.tsx new file mode 100644 index 0000000..db52dc5 --- /dev/null +++ b/apps/taxes/src/modules/avatax/ui/avatax-configuration-taxes-fragment.tsx @@ -0,0 +1,34 @@ +import { TextLink } from "@saleor/apps-ui"; +import { Input } from "@saleor/react-hook-form-macaw"; +import React from "react"; +import { useFormContext } from "react-hook-form"; +import { AvataxConfig } from "../avatax-connection-schema"; +import { HelperText } from "./form-helper-text"; +import { FormSection } from "./form-section"; +import { useAvataxConfigurationStatus } from "./configuration-status"; + +export const AvataxConfigurationTaxesFragment = () => { + const { control, formState } = useFormContext(); + const { status } = useAvataxConfigurationStatus(); + const disabled = status === "not_authenticated"; + + return ( + +
+ + + Tax code that for the shipping line sent to Avatax.{" "} + + Must match Avatax tax codes format. + + +
+
+ ); +}; diff --git a/apps/taxes/src/modules/avatax/ui/avatax-instructions.tsx b/apps/taxes/src/modules/avatax/ui/avatax-instructions.tsx index f29bda0..aa5b413 100644 --- a/apps/taxes/src/modules/avatax/ui/avatax-instructions.tsx +++ b/apps/taxes/src/modules/avatax/ui/avatax-instructions.tsx @@ -8,11 +8,13 @@ export const AvataxInstructions = () => { title="Avatax Configuration" description={ <> - The form consists of two sections: Credentials and Address. -
-
- Credentials will fail if: - + + The form consists of two sections: Credentials and Address. + + + Credentials will fail if: + +
  • - The username or password are incorrect.
  • @@ -23,11 +25,14 @@ export const AvataxInstructions = () => {
    -
    -
    - Address will fail if: -
    - + + You must verify the credentials by clicking the Verify{" "} + button. + + + Address will fail if: + +
  • - The address does not match{" "} @@ -38,13 +43,15 @@ export const AvataxInstructions = () => {
  • -
    -
    - If the configuration fails, please visit the{" "} - - Avatax documentation - - . + + You must verify the address by clicking the Verify{" "} + button. + + + Verifying the Address will display suggestions that reflect the resolution of the + address by Avatax address validation service. Applying the suggestions is not required + but recommended. If the address is not valid, the calculation of taxes will fail. + } /> diff --git a/apps/taxes/src/modules/avatax/ui/configuration-status.ts b/apps/taxes/src/modules/avatax/ui/configuration-status.ts new file mode 100644 index 0000000..e2313da --- /dev/null +++ b/apps/taxes/src/modules/avatax/ui/configuration-status.ts @@ -0,0 +1,11 @@ +import { atom, useAtom } from "jotai"; + +const statusAtom = atom< + "not_authenticated" | "authenticated" | "address_valid" | "address_invalid" | "address_verified" +>("not_authenticated"); + +export const useAvataxConfigurationStatus = () => { + const [status, setStatus] = useAtom(statusAtom); + + return { status, setStatus }; +}; diff --git a/apps/taxes/src/modules/avatax/ui/create-avatax-configuration.tsx b/apps/taxes/src/modules/avatax/ui/create-avatax-configuration.tsx index 6237735..375d788 100644 --- a/apps/taxes/src/modules/avatax/ui/create-avatax-configuration.tsx +++ b/apps/taxes/src/modules/avatax/ui/create-avatax-configuration.tsx @@ -1,6 +1,6 @@ import React from "react"; import { AvataxConfigurationForm } from "./avatax-configuration-form"; -import { AvataxConfig, defaultAvataxConfig } from "../avatax-connection-schema"; +import { AvataxConfig, BaseAvataxConfig, defaultAvataxConfig } from "../avatax-connection-schema"; import { trpcClient } from "../../trpc/trpc-client"; import { useDashboardNotification } from "@saleor/apps-shared"; import { useRouter } from "next/router"; @@ -25,6 +25,25 @@ export const CreateAvataxConfiguration = () => { }, }); + const validateAddressMutation = trpcClient.avataxConnection.createValidateAddress.useMutation({}); + + const validateAddressHandler = React.useCallback( + async (config: AvataxConfig) => { + return validateAddressMutation.mutateAsync({ value: config }); + }, + [validateAddressMutation] + ); + + const validateCredentialsMutation = + trpcClient.avataxConnection.createValidateCredentials.useMutation({}); + + const validateCredentialsHandler = React.useCallback( + async (config: BaseAvataxConfig) => { + return validateCredentialsMutation.mutateAsync({ value: config }); + }, + [validateCredentialsMutation] + ); + const submitHandler = React.useCallback( (data: AvataxConfig) => { createMutation({ value: data }); @@ -32,10 +51,32 @@ export const CreateAvataxConfiguration = () => { [createMutation] ); + const submit = React.useMemo(() => { + return { + isLoading: isCreateLoading, + handleFn: submitHandler, + }; + }, [isCreateLoading, submitHandler]); + + const validateAddress = React.useMemo(() => { + return { + isLoading: validateAddressMutation.isLoading, + handleFn: validateAddressHandler, + }; + }, [validateAddressHandler, validateAddressMutation]); + + const validateCredentials = React.useMemo(() => { + return { + isLoading: validateCredentialsMutation.isLoading, + handleFn: validateCredentialsHandler, + }; + }, [validateCredentialsHandler, validateCredentialsMutation]); + return ( { + const { setStatus } = useAvataxConfigurationStatus(); + const router = useRouter(); const { id } = router.query; @@ -19,6 +22,11 @@ export const EditAvataxConfiguration = () => { const { refetch: refetchProvidersConfigurationData } = trpcClient.providersConfiguration.getAll.useQuery(); + React.useEffect(() => { + // When editing, we know the address is verified (because it was validated when creating the configuration) + setStatus("address_verified"); + }, [setStatus]); + const { notifySuccess, notifyError } = useDashboardNotification(); const { mutate: patchMutation, isLoading: isPatchLoading } = trpcClient.avataxConnection.update.useMutation({ @@ -59,6 +67,7 @@ export const EditAvataxConfiguration = () => { const submitHandler = React.useCallback( (data: AvataxConfig) => { patchMutation({ + // todo: remove obfuscation value: avataxObfuscator.filterOutObfuscated(data), id: configurationId, }); @@ -75,6 +84,46 @@ export const EditAvataxConfiguration = () => { // } }; + const validateAddressMutation = trpcClient.avataxConnection.editValidateAddress.useMutation({}); + + const validateAddressHandler = React.useCallback( + async (config: AvataxConfig) => { + return validateAddressMutation.mutateAsync({ id: configurationId, value: config }); + }, + [configurationId, validateAddressMutation] + ); + + const validateCredentialsMutation = + trpcClient.avataxConnection.editValidateCredentials.useMutation({}); + + const validateCredentialsHandler = React.useCallback( + async (config: BaseAvataxConfig) => { + return validateCredentialsMutation.mutateAsync({ id: configurationId, value: config }); + }, + [configurationId, validateCredentialsMutation] + ); + + const submit = React.useMemo(() => { + return { + isLoading: isPatchLoading, + handleFn: submitHandler, + }; + }, [isPatchLoading, submitHandler]); + + const validateAddress = React.useMemo(() => { + return { + isLoading: validateAddressMutation.isLoading, + handleFn: validateAddressHandler, + }; + }, [validateAddressHandler, validateAddressMutation]); + + const validateCredentials = React.useMemo(() => { + return { + isLoading: validateCredentialsMutation.isLoading, + handleFn: validateCredentialsHandler, + }; + }, [validateCredentialsHandler, validateCredentialsMutation]); + if (isGetLoading) { // todo: replace with skeleton once its available in Macaw return ( @@ -91,10 +140,12 @@ export const EditAvataxConfiguration = () => {
    ); } + return ( diff --git a/apps/taxes/src/modules/avatax/ui/form-helper-text.tsx b/apps/taxes/src/modules/avatax/ui/form-helper-text.tsx new file mode 100644 index 0000000..72d6be3 --- /dev/null +++ b/apps/taxes/src/modules/avatax/ui/form-helper-text.tsx @@ -0,0 +1,19 @@ +import { Text } from "@saleor/macaw-ui/next"; +import React from "react"; + +export const HelperText = ({ + children, + disabled = false, +}: { + children: React.ReactNode; + disabled?: boolean; +}) => { + return ( + + {children} + + ); +}; diff --git a/apps/taxes/src/modules/avatax/ui/form-section.tsx b/apps/taxes/src/modules/avatax/ui/form-section.tsx new file mode 100644 index 0000000..8960452 --- /dev/null +++ b/apps/taxes/src/modules/avatax/ui/form-section.tsx @@ -0,0 +1,30 @@ +import { Box, Text } from "@saleor/macaw-ui/next"; +import React, { PropsWithChildren } from "react"; + +export const FormSection = ({ + title, + subtitle, + children, + disabled = false, +}: PropsWithChildren<{ title: string; subtitle?: string; disabled?: boolean }>) => { + return ( + <> + + {title} + + {subtitle && ( + + {subtitle} + + )} + + {children} + + + ); +}; diff --git a/apps/taxes/src/modules/provider-connections/provider-connections.router.ts b/apps/taxes/src/modules/provider-connections/provider-connections.router.ts index f5df298..8350742 100644 --- a/apps/taxes/src/modules/provider-connections/provider-connections.router.ts +++ b/apps/taxes/src/modules/provider-connections/provider-connections.router.ts @@ -9,11 +9,11 @@ export const providerConnectionsRouter = router({ name: "providerConnectionsRouter.getAll", }); - const items = await new PublicProviderConnectionsService( - ctx.apiClient, - ctx.appId!, - ctx.saleorApiUrl - ).getAll(); + const items = await new PublicProviderConnectionsService({ + appId: ctx.appId!, + client: ctx.apiClient, + saleorApiUrl: ctx.saleorApiUrl, + }).getAll(); logger.info("Returning tax providers configuration"); 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 index c16ebc6..3fffd56 100644 --- a/apps/taxes/src/modules/provider-connections/public-provider-connections.service.ts +++ b/apps/taxes/src/modules/provider-connections/public-provider-connections.service.ts @@ -9,9 +9,25 @@ 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); + constructor({ + client, + appId, + saleorApiUrl, + }: { + client: Client; + appId: string; + saleorApiUrl: string; + }) { + this.avataxConnectionService = new PublicAvataxConnectionService({ + client, + appId, + saleorApiUrl, + }); + this.taxJarConnectionService = new PublicTaxJarConnectionService({ + client, + appId, + saleorApiUrl, + }); this.logger = createLogger({ name: "PublicProviderConnectionsService", metadataKey: TAX_PROVIDER_KEY, 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 index f6cf40d..39b3422 100644 --- a/apps/taxes/src/modules/taxjar/configuration/public-taxjar-connection.service.ts +++ b/apps/taxes/src/modules/taxjar/configuration/public-taxjar-connection.service.ts @@ -7,8 +7,16 @@ 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); + constructor({ + client, + appId, + saleorApiUrl, + }: { + client: Client; + appId: string; + saleorApiUrl: string; + }) { + this.connectionService = new TaxJarConnectionService({ client, appId, saleorApiUrl }); this.obfuscator = new TaxJarConnectionObfuscator(); } diff --git a/apps/taxes/src/modules/taxjar/configuration/taxjar-connection.service.ts b/apps/taxes/src/modules/taxjar/configuration/taxjar-connection.service.ts index 11b00da..a6ae689 100644 --- a/apps/taxes/src/modules/taxjar/configuration/taxjar-connection.service.ts +++ b/apps/taxes/src/modules/taxjar/configuration/taxjar-connection.service.ts @@ -9,7 +9,15 @@ import { TaxJarValidationService } from "./taxjar-validation.service"; export class TaxJarConnectionService { private logger: Logger; private taxJarConnectionRepository: TaxJarConnectionRepository; - constructor(client: Client, appId: string, saleorApiUrl: string) { + constructor({ + client, + appId, + saleorApiUrl, + }: { + client: Client; + appId: string; + saleorApiUrl: string; + }) { this.logger = createLogger({ name: "TaxJarConnectionService", }); diff --git a/apps/taxes/src/modules/taxjar/tax-code/taxjar-tax-codes.router.ts b/apps/taxes/src/modules/taxjar/tax-code/taxjar-tax-codes.router.ts index 99abae3..4b5eb87 100644 --- a/apps/taxes/src/modules/taxjar/tax-code/taxjar-tax-codes.router.ts +++ b/apps/taxes/src/modules/taxjar/tax-code/taxjar-tax-codes.router.ts @@ -15,11 +15,11 @@ export const taxJarTaxCodesRouter = router({ name: "taxjarTaxCodesRouter.getAllForId", }); - const connectionService = new TaxJarConnectionService( - ctx.apiClient, - ctx.appId!, - ctx.saleorApiUrl - ); + const connectionService = new TaxJarConnectionService({ + appId: ctx.appId!, + client: ctx.apiClient, + saleorApiUrl: ctx.saleorApiUrl, + }); const connection = await connectionService.getById(input.connectionId); const taxCodesService = new TaxJarTaxCodesService(connection.config); diff --git a/apps/taxes/src/modules/taxjar/taxjar-connection.router.ts b/apps/taxes/src/modules/taxjar/taxjar-connection.router.ts index 9f67d2d..3350627 100644 --- a/apps/taxes/src/modules/taxjar/taxjar-connection.router.ts +++ b/apps/taxes/src/modules/taxjar/taxjar-connection.router.ts @@ -25,11 +25,11 @@ const postInputSchema = z.object({ const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) => next({ ctx: { - connectionService: new PublicTaxJarConnectionService( - ctx.apiClient, - ctx.appId!, - ctx.saleorApiUrl - ), + connectionService: new PublicTaxJarConnectionService({ + appId: ctx.appId!, + client: ctx.apiClient, + saleorApiUrl: ctx.saleorApiUrl, + }), }, }) ); diff --git a/apps/taxes/src/modules/ui/app-section.tsx b/apps/taxes/src/modules/ui/app-section.tsx index 3eb488a..f2bf096 100644 --- a/apps/taxes/src/modules/ui/app-section.tsx +++ b/apps/taxes/src/modules/ui/app-section.tsx @@ -26,9 +26,9 @@ const Description = ({ {title} - + {description} - +
    ); }; diff --git a/apps/taxes/src/pages/providers/avatax/[id].tsx b/apps/taxes/src/pages/providers/avatax/[id].tsx index c3e7989..7686576 100644 --- a/apps/taxes/src/pages/providers/avatax/[id].tsx +++ b/apps/taxes/src/pages/providers/avatax/[id].tsx @@ -1,3 +1,4 @@ +import { Provider } from "jotai"; import { AvataxInstructions } from "../../../modules/avatax/ui/avatax-instructions"; import { EditAvataxConfiguration } from "../../../modules/avatax/ui/edit-avatax-configuration"; import { AppColumns } from "../../../modules/ui/app-columns"; @@ -12,7 +13,9 @@ const EditAvataxPage = () => {
    }> - + + +
    ); diff --git a/apps/taxes/src/pages/providers/avatax/index.tsx b/apps/taxes/src/pages/providers/avatax/index.tsx index f62b3c6..e9ba860 100644 --- a/apps/taxes/src/pages/providers/avatax/index.tsx +++ b/apps/taxes/src/pages/providers/avatax/index.tsx @@ -1,7 +1,8 @@ import { Box, Text } from "@saleor/macaw-ui/next"; +import { Provider } from "jotai"; +import { AvataxInstructions } from "../../../modules/avatax/ui/avatax-instructions"; import { CreateAvataxConfiguration } from "../../../modules/avatax/ui/create-avatax-configuration"; import { AppColumns } from "../../../modules/ui/app-columns"; -import { AvataxInstructions } from "../../../modules/avatax/ui/avatax-instructions"; const Header = () => { return ( @@ -18,7 +19,9 @@ const NewAvataxPage = () => {
    }> - + + +
    );