feat: address validation suggestions (#802)

* feat:  add ping method to avatax-client

* refactor: ♻️ use avatax-auth-validation.service instead of address service

* refactor: ♻️ extract avatax-configuration-address-fragment

* refactor: ♻️ extract avatax-configuration-credentials-fragment

* refactor: ♻️ extract form-helper-text

* refactor: ♻️ extract form-section

* refactor: ♻️ extract avatax-configuration-taxes-fragment

* feat:  move verify to credentials fragment && add disabled form section

* refactor: 🚚 obfuscator

* feat:  add separate credentials and address validation services

* build: 👷 add changeset

* feat:  add address resolution message

* fix: 🐛 changeset

* refactor: ♻️ extract avataxAddressResolutionProcessor and add tests

* refactor:

* refactor: ♻️ remove brs from avatax-instructions

* refactor: ♻️ replace b with Text bodyStrong

* refactor: ♻️ state tuple to object

* refactor: ♻️ destructure some more constructors

* refactor: ♻️ memoize isLoadings & handlers
This commit is contained in:
Adrian Pilarczyk 2023-07-25 11:15:18 +02:00 committed by GitHub
parent aa6fec183d
commit 5a4da7beed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1281 additions and 338 deletions

View file

@ -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.

View file

@ -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,
};

View file

@ -1,12 +1,12 @@
import Avatax from "avatax"; 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 { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel";
import packageJson from "../../../package.json"; import packageJson from "../../../package.json";
import { createLogger, Logger } from "../../lib/logger"; 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 { AvataxClientTaxCodeService } from "./avatax-client-tax-code.service";
import { BaseAvataxConfig } from "./avatax-connection-schema";
type AvataxSettings = { type AvataxSettings = {
appName: string; appName: string;
@ -29,10 +29,10 @@ const defaultAvataxSettings: AvataxSettings = {
timeout: 5000, timeout: 5000,
}; };
const createAvataxSettings = (config: AvataxConfig): AvataxSettings => { const createAvataxSettings = ({ isSandbox }: { isSandbox: boolean }): AvataxSettings => {
const settings: AvataxSettings = { const settings: AvataxSettings = {
...defaultAvataxSettings, ...defaultAvataxSettings,
environment: config.isSandbox ? "sandbox" : "production", environment: isSandbox ? "sandbox" : "production",
}; };
return settings; return settings;
@ -57,10 +57,10 @@ export class AvataxClient {
private client: Avatax; private client: Avatax;
private logger: Logger; private logger: Logger;
constructor(config: AvataxConfig) { constructor(baseConfig: BaseAvataxConfig) {
this.logger = createLogger({ name: "AvataxClient" }); this.logger = createLogger({ name: "AvataxClient" });
const settings = createAvataxSettings(config); const settings = createAvataxSettings({ isSandbox: baseConfig.isSandbox });
const avataxClient = new Avatax(settings).withSecurity(config.credentials); const avataxClient = new Avatax(settings).withSecurity(baseConfig.credentials);
this.client = avataxClient; this.client = avataxClient;
} }
@ -82,4 +82,8 @@ export class AvataxClient {
return taxCodeService.getTaxCodes(); return taxCodeService.getTaxCodes();
} }
async ping() {
return this.client.ping();
}
} }

View file

@ -13,16 +13,24 @@ const avataxCredentialsSchema = z.object({
password: z.string().min(1, { message: "Password requires at least one character." }), password: z.string().min(1, { message: "Password requires at least one character." }),
}); });
export const avataxConfigSchema = z.object({ // All that is needed to create Avatax configuration.
name: z.string().min(1, { message: "Name requires at least one character." }), export const baseAvataxConfigSchema = z.object({
isSandbox: z.boolean(), isSandbox: z.boolean(),
companyCode: z.string().optional(),
isAutocommit: z.boolean(),
shippingTaxCode: z.string().optional(),
credentials: avataxCredentialsSchema, credentials: avataxCredentialsSchema,
address: addressSchema,
}); });
export type BaseAvataxConfig = z.infer<typeof baseAvataxConfigSchema>;
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<typeof avataxConfigSchema>; export type AvataxConfig = z.infer<typeof avataxConfigSchema>;
export const defaultAvataxConfig: AvataxConfig = { export const defaultAvataxConfig: AvataxConfig = {

View file

@ -2,7 +2,12 @@ import { z } from "zod";
import { createLogger } from "../../lib/logger"; import { createLogger } from "../../lib/logger";
import { protectedClientProcedure } from "../trpc/protected-client-procedure"; import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server"; 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"; import { PublicAvataxConnectionService } from "./configuration/public-avatax-connection.service";
const getInputSchema = z.object({ const getInputSchema = z.object({
@ -22,20 +27,20 @@ const postInputSchema = z.object({
value: avataxConfigSchema, value: avataxConfigSchema,
}); });
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) => const protectedWithConnectionService = protectedClientProcedure.use(({ next, ctx }) =>
next({ next({
ctx: { ctx: {
connectionService: new PublicAvataxConnectionService( connectionService: new PublicAvataxConnectionService({
ctx.apiClient, appId: ctx.appId!,
ctx.appId!, client: ctx.apiClient,
ctx.saleorApiUrl saleorApiUrl: ctx.saleorApiUrl,
), }),
}, },
}) })
); );
export const avataxConnectionRouter = router({ export const avataxConnectionRouter = router({
verifyConnections: protectedWithConfigurationService.query(async ({ ctx }) => { verifyConnections: protectedWithConnectionService.query(async ({ ctx }) => {
const logger = createLogger({ const logger = createLogger({
name: "avataxConnectionRouter.verifyConnections", name: "avataxConnectionRouter.verifyConnections",
}); });
@ -48,7 +53,7 @@ export const avataxConnectionRouter = router({
return { ok: true }; return { ok: true };
}), }),
getById: protectedWithConfigurationService.input(getInputSchema).query(async ({ ctx, input }) => { getById: protectedWithConnectionService.input(getInputSchema).query(async ({ ctx, input }) => {
const logger = createLogger({ const logger = createLogger({
name: "avataxConnectionRouter.get", name: "avataxConnectionRouter.get",
}); });
@ -61,23 +66,21 @@ export const avataxConnectionRouter = router({
return result; return result;
}), }),
create: protectedWithConfigurationService create: protectedWithConnectionService.input(postInputSchema).mutation(async ({ ctx, input }) => {
.input(postInputSchema) const logger = createLogger({
.mutation(async ({ ctx, input }) => { saleorApiUrl: ctx.saleorApiUrl,
const logger = createLogger({ procedure: "avataxConnectionRouter.post",
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; return result;
}), }),
delete: protectedWithConfigurationService delete: protectedWithConnectionService
.input(deleteInputSchema) .input(deleteInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const logger = createLogger({ const logger = createLogger({
@ -93,7 +96,7 @@ export const avataxConnectionRouter = router({
return result; return result;
}), }),
update: protectedWithConfigurationService update: protectedWithConnectionService
.input(patchInputSchema) .input(patchInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const logger = createLogger({ const logger = createLogger({
@ -107,6 +110,98 @@ export const avataxConnectionRouter = router({
logger.info(`Avatax configuration with an id: ${input.id} was successfully updated`); 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; return result;
}), }),
}); });

View file

@ -1,11 +1,11 @@
import { AvataxConfigMockGenerator } from "./avatax-config-mock-generator"; import { AvataxConfigMockGenerator } from "./avatax-config-mock-generator";
import { AvataxConnectionObfuscator } from "./avatax-connection-obfuscator"; import { AvataxObfuscator } from "./avatax-obfuscator";
import { expect, it, describe } from "vitest"; import { expect, it, describe } from "vitest";
const mockAvataxConfig = new AvataxConfigMockGenerator().generateAvataxConfig(); const mockAvataxConfig = new AvataxConfigMockGenerator().generateAvataxConfig();
const obfuscator = new AvataxConnectionObfuscator(); const obfuscator = new AvataxObfuscator();
describe("AvataxConnectionObfuscator", () => { describe("AvataxObfuscator", () => {
it("obfuscated avatax config", () => { it("obfuscated avatax config", () => {
const obfuscatedConfig = obfuscator.obfuscateAvataxConfig(mockAvataxConfig); const obfuscatedConfig = obfuscator.obfuscateAvataxConfig(mockAvataxConfig);

View file

@ -1,7 +1,7 @@
import { Obfuscator } from "../../lib/obfuscator"; import { Obfuscator } from "../../lib/obfuscator";
import { AvataxConfig, AvataxConnection } from "./avatax-connection-schema"; import { AvataxConfig, AvataxConnection } from "./avatax-connection-schema";
export class AvataxConnectionObfuscator { export class AvataxObfuscator {
private obfuscator = new Obfuscator(); private obfuscator = new Obfuscator();
obfuscateAvataxConfig = (config: AvataxConfig): AvataxConfig => { obfuscateAvataxConfig = (config: AvataxConfig): AvataxConfig => {

View file

@ -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);
}
}
}

View file

@ -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);
}
}
}

View file

@ -4,12 +4,22 @@ import { Logger, createLogger } from "../../../lib/logger";
import { createSettingsManager } from "../../app/metadata-manager"; import { createSettingsManager } from "../../app/metadata-manager";
import { AvataxConfig, AvataxConnection } from "../avatax-connection-schema"; import { AvataxConfig, AvataxConnection } from "../avatax-connection-schema";
import { AvataxConnectionRepository } from "./avatax-connection-repository"; 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 { export class AvataxConnectionService {
private logger: Logger; private logger: Logger;
private avataxConnectionRepository: AvataxConnectionRepository; private avataxConnectionRepository: AvataxConnectionRepository;
constructor(client: Client, appId: string, saleorApiUrl: string) {
constructor({
client,
appId,
saleorApiUrl,
}: {
client: Client;
appId: string;
saleorApiUrl: string;
}) {
this.logger = createLogger({ this.logger = createLogger({
name: "AvataxConnectionService", name: "AvataxConnectionService",
}); });
@ -19,6 +29,13 @@ export class AvataxConnectionService {
this.avataxConnectionRepository = new AvataxConnectionRepository(settingsManager, saleorApiUrl); 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<AvataxConnection[]> { getAll(): Promise<AvataxConnection[]> {
return this.avataxConnectionRepository.getAll(); return this.avataxConnectionRepository.getAll();
} }
@ -27,12 +44,10 @@ export class AvataxConnectionService {
return this.avataxConnectionRepository.get(id); return this.avataxConnectionRepository.get(id);
} }
async create(config: AvataxConfig): Promise<{ id: string }> { async create(input: AvataxConfig): Promise<{ id: string }> {
const validationService = new AvataxValidationService(); await this.checkIfAuthorized(input);
await validationService.validate(config); return this.avataxConnectionRepository.post(input);
return await this.avataxConnectionRepository.post(config);
} }
async update(id: string, nextConfigPartial: DeepPartial<AvataxConfig>): Promise<void> { async update(id: string, nextConfigPartial: DeepPartial<AvataxConfig>): Promise<void> {
@ -41,8 +56,6 @@ export class AvataxConnectionService {
const { id: _, ...setting } = data; const { id: _, ...setting } = data;
const prevConfig = setting.config; const prevConfig = setting.config;
const validationService = new AvataxValidationService();
// todo: add deepRightMerge // todo: add deepRightMerge
const input: AvataxConfig = { const input: AvataxConfig = {
...prevConfig, ...prevConfig,
@ -57,7 +70,7 @@ export class AvataxConnectionService {
}, },
}; };
await validationService.validate(input); await this.checkIfAuthorized(input);
return this.avataxConnectionRepository.patch(id, { config: input }); return this.avataxConnectionRepository.patch(id, { config: input });
} }

View file

@ -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);
}
}

View file

@ -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<AvataxConfig, "credentials" | "isSandbox">) {
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();
}
}

View file

@ -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;
}
}

View file

@ -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<void> {
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);
}
}
}

View file

@ -1,15 +1,23 @@
import { DeepPartial } from "@trpc/server"; import { DeepPartial } from "@trpc/server";
import { Client } from "urql"; import { Client } from "urql";
import { AvataxConnectionObfuscator } from "../avatax-connection-obfuscator";
import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxObfuscator } from "../avatax-obfuscator";
import { AvataxConnectionService } from "./avatax-connection.service"; import { AvataxConnectionService } from "./avatax-connection.service";
export class PublicAvataxConnectionService { export class PublicAvataxConnectionService {
private readonly connectionService: AvataxConnectionService; private readonly connectionService: AvataxConnectionService;
private readonly obfuscator: AvataxConnectionObfuscator; private readonly obfuscator: AvataxObfuscator;
constructor(client: Client, appId: string, saleorApiUrl: string) { constructor({
this.connectionService = new AvataxConnectionService(client, appId, saleorApiUrl); client,
this.obfuscator = new AvataxConnectionObfuscator(); appId,
saleorApiUrl,
}: {
client: Client;
appId: string;
saleorApiUrl: string;
}) {
this.connectionService = new AvataxConnectionService({ client, appId, saleorApiUrl });
this.obfuscator = new AvataxObfuscator();
} }
async getAll() { async getAll() {

View file

@ -14,11 +14,11 @@ export const avataxTaxCodesRouter = router({
name: "avataxTaxCodesRouter.getAllForId", name: "avataxTaxCodesRouter.getAllForId",
}); });
const connectionService = new AvataxConnectionService( const connectionService = new AvataxConnectionService({
ctx.apiClient, appId: ctx.appId!,
ctx.appId!, client: ctx.apiClient,
ctx.saleorApiUrl saleorApiUrl: ctx.saleorApiUrl,
); });
const connection = await connectionService.getById(input.connectionId); const connection = await connectionService.getById(input.connectionId);
const taxCodesService = new AvataxTaxCodesService(connection.config); const taxCodesService = new AvataxTaxCodesService(connection.config);

View file

@ -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",
});
});
});
});

View file

@ -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.",
};
}
}

View file

@ -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 (
<Box alignItems={"center"} display={"flex"} justifyContent={"space-between"} marginTop={1}>
<Text paddingLeft={2} color="textNeutralSubdued" variant="caption">
{suggestion}
</Text>
</Box>
);
};
export type AddressSuggestions = AvataxConfig["address"];
const avataxAddressResolutionProcessor = new AvataxAddressResolutionProcessor();
type AvataxConfigurationAddressFragmentProps = {
onValidateAddress: (address: AvataxConfig) => Promise<AddressResolutionModel>;
isLoading: boolean;
};
export const AvataxConfigurationAddressFragment = (
props: AvataxConfigurationAddressFragmentProps
) => {
const { control, formState, getValues, setValue, watch } = useFormContext<AvataxConfig>();
const { status, setStatus } = useAvataxConfigurationStatus();
const [suggestions, setSuggestions] = React.useState<AddressSuggestions>();
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 (
<>
<FormSection
title="Address"
disabled={disabled}
subtitle={disabled ? "You must verify your Credentials first." : ""}
>
<Box display="flex" flexDirection={"column"}>
<Input
control={control}
disabled={disabled}
name="address.street"
label="Street"
helperText={formState.errors.address?.street?.message}
/>
{suggestions?.street && <FieldSuggestion suggestion={suggestions.street} />}
</Box>
<Box>
<Input
control={control}
disabled={disabled}
name="address.city"
label="City"
helperText={formState.errors.address?.city?.message}
/>
{suggestions?.city && <FieldSuggestion suggestion={suggestions.city} />}
</Box>
<Box>
<Input
control={control}
disabled={disabled}
name="address.state"
label="State"
helperText={formState.errors.address?.state?.message}
/>
{suggestions?.state && <FieldSuggestion suggestion={suggestions.state} />}
</Box>
<CountrySelect
control={control}
disabled={disabled}
name="address.country"
label="Country"
helperText={formState.errors.address?.country?.message}
/>
<Box>
<Input
control={control}
disabled={disabled}
name="address.zip"
label="Zip"
helperText={formState.errors.address?.zip?.message}
/>
{suggestions?.zip && <FieldSuggestion suggestion={suggestions.zip} />}
</Box>
</FormSection>
<Box display={"flex"} flexDirection={"column"} gap={6} marginTop={8}>
{status !== "address_valid" && (
<Box alignItems={"center"} display="flex" justifyContent={"flex-end"}>
<Button
disabled={props.isLoading || status === "not_authenticated"}
onClick={verifyClickHandler}
variant="secondary"
>
{props.isLoading ? "Verifying..." : "Verify"}
</Button>
</Box>
)}
{suggestions && (
<Box display={"flex"} justifyContent={"flex-end"}>
<Box display={"flex"} gap={4}>
<Button variant="secondary" onClick={rejectClickHandler}>
Reject suggestions
</Button>
<Button onClick={applyClickHandler} variant="primary">
Apply suggestions
</Button>
</Box>
</Box>
)}
</Box>
</>
);
};

View file

@ -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<void>;
isLoading: boolean;
};
export const AvataxConfigurationCredentialsFragment = (
props: AvataxConfigurationCredentialsFragmentProps
) => {
const { control, formState, getValues, watch } = useFormContext<AvataxConfig>();
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 (
<>
<FormSection title="Credentials">
<Box paddingY={4} display={"flex"} flexDirection={"column"} gap={10}>
<div>
<Input
control={control}
name="credentials.username"
required
label="Username *"
helperText={formState.errors.credentials?.username?.message}
/>
<HelperText>
You can obtain it in the <i>API Keys</i> section of <i>Settings</i> <i>License</i>{" "}
in your Avalara Dashboard.
</HelperText>
</div>
<div>
<Input
control={control}
name="credentials.password"
type="password"
required
label="Password *"
helperText={formState.errors.credentials?.password?.message}
/>
<HelperText>
You can obtain it in the <i>API Keys</i> section of <i>Settings</i> <i>License</i>{" "}
in your Avalara Dashboard.
</HelperText>
</div>
<div>
<Input
control={control}
name="companyCode"
label="Company name"
helperText={formState.errors.companyCode?.message}
/>
<HelperText>
When not provided, the default company will be used.{" "}
<TextLink
newTab
href="https://developer.avalara.com/erp-integration-guide/sales-tax-badge/transactions/simple-transactions/company-codes/"
>
Read more
</TextLink>{" "}
about company codes.
</HelperText>
</div>
</Box>
<Box paddingY={4} display={"flex"} flexDirection={"column"} gap={10}>
<AppToggle
control={control}
label="Use sandbox mode"
helperText={
<HelperText>
Toggling between{" "}
<TextLink
href="https://developer.avalara.com/erp-integration-guide/sales-tax-badge/authentication-in-avatax/sandbox-vs-production/"
newTab
>
<q>Production</q> and <q>Sandbox</q>
</TextLink>{" "}
environment.
</HelperText>
}
name="isSandbox"
/>
<AppToggle
control={control}
label="Autocommit"
helperText={
<HelperText>
If enabled, the order will be automatically{" "}
<TextLink
href="https://developer.avalara.com/communications/dev-guide_rest_v2/commit-uncommit/"
newTab
>
commited to Avalara.
</TextLink>{" "}
</HelperText>
}
name="isAutocommit"
/>
</Box>
</FormSection>
<Box display="flex" justifyContent={"flex-end"}>
<Button variant="secondary" onClick={verifyCredentials}>
{props.isLoading ? "Verifying..." : "Verify"}
</Button>
</Box>
</>
);
};

View file

@ -1,43 +1,56 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { TextLink } from "@saleor/apps-ui"; import { Box, Button, Divider } from "@saleor/macaw-ui/next";
import { Box, Button, Divider, Text } from "@saleor/macaw-ui/next";
import { Input } from "@saleor/react-hook-form-macaw"; import { Input } from "@saleor/react-hook-form-macaw";
import { AddressResolutionModel } from "avatax/lib/models/AddressResolutionModel";
import React from "react"; import React from "react";
import { useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { AppCard } from "../../ui/app-card"; 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 { ProviderLabel } from "../../ui/provider-label";
import { AvataxConfig, avataxConfigSchema, defaultAvataxConfig } from "../avatax-connection-schema"; import {
AvataxConfig,
const HelperText = ({ children }: { children: React.ReactNode }) => { BaseAvataxConfig,
return ( avataxConfigSchema,
<Text color="textNeutralSubdued" fontWeight={"captionLarge"}> defaultAvataxConfig,
{children} } from "../avatax-connection-schema";
</Text> 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 = { type AvataxConfigurationFormProps = {
onSubmit: (data: AvataxConfig) => void; submit: {
handleFn: (data: AvataxConfig) => void;
isLoading: boolean;
};
validateAddress: {
handleFn: (config: AvataxConfig) => Promise<AddressResolutionModel>;
isLoading: boolean;
};
validateCredentials: {
handleFn: (config: BaseAvataxConfig) => Promise<void>;
isLoading: boolean;
};
defaultValues: AvataxConfig; defaultValues: AvataxConfig;
isLoading: boolean;
leftButton: React.ReactNode; leftButton: React.ReactNode;
}; };
export const AvataxConfigurationForm = (props: AvataxConfigurationFormProps) => { export const AvataxConfigurationForm = (props: AvataxConfigurationFormProps) => {
const { handleSubmit, control, formState, reset } = useForm({ const { status } = useAvataxConfigurationStatus();
const formMethods = useForm({
defaultValues: defaultAvataxConfig, defaultValues: defaultAvataxConfig,
resolver: zodResolver(avataxConfigSchema), resolver: zodResolver(avataxConfigSchema),
}); });
const { handleSubmit, control, formState, reset } = formMethods;
React.useEffect(() => { React.useEffect(() => {
reset(props.defaultValues); reset(props.defaultValues);
}, [props.defaultValues, reset]); }, [props.defaultValues, reset]);
const submitHandler = React.useCallback( const submitHandler = React.useCallback(
(data: AvataxConfig) => { (data: AvataxConfig) => {
props.onSubmit(data); props.submit.handleFn(data);
}, },
[props] [props]
); );
@ -47,182 +60,44 @@ export const AvataxConfigurationForm = (props: AvataxConfigurationFormProps) =>
<Box marginBottom={8}> <Box marginBottom={8}>
<ProviderLabel name="avatax" /> <ProviderLabel name="avatax" />
</Box> </Box>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(submitHandler)} data-testid="avatax-configuration-form">
<Input
control={control}
name="name"
required
label="Configuration name *"
helperText={formState.errors.name?.message}
/>
<HelperText>Unique identifier for your provider.</HelperText>
<Divider marginY={8} />
<AvataxConfigurationCredentialsFragment
onValidateCredentials={props.validateCredentials.handleFn}
isLoading={props.validateCredentials.isLoading}
/>
<Divider marginY={8} />
<AvataxConfigurationAddressFragment
onValidateAddress={props.validateAddress.handleFn}
isLoading={props.validateAddress.isLoading}
/>
<Divider marginY={8} />
<AvataxConfigurationTaxesFragment />
<Divider marginY={8} />
<form onSubmit={handleSubmit(submitHandler)} data-testid="avatax-configuration-form"> <Box display={"flex"} justifyContent={"space-between"} alignItems={"center"}>
<Input {props.leftButton}
control={control}
name="name"
required
label="Configuration name *"
helperText={formState.errors.name?.message}
/>
<HelperText>Unique identifier for your provider.</HelperText>
<Divider marginY={8} />
<Text marginBottom={4} as="h3" variant="heading">
Credentials
</Text>
<Box display="grid" gridTemplateColumns={2} gap={12}>
<Box paddingY={4} display={"flex"} flexDirection={"column"} gap={10}>
<div>
<Input
control={control}
name="credentials.username"
required
label="Username *"
helperText={formState.errors.credentials?.username?.message}
/>
<HelperText>
You can obtain it in the <i>API Keys</i> section of <i>Settings</i> <i>License</i>{" "}
in your Avalara Dashboard.
</HelperText>
</div>
<div>
<Input
control={control}
name="credentials.password"
type="password"
required
label="Password *"
helperText={formState.errors.credentials?.password?.message}
/>
<HelperText>
You can obtain it in the <i>API Keys</i> section of <i>Settings</i> <i>License</i>{" "}
in your Avalara Dashboard.
</HelperText>
</div>
<div> <Button
<Input disabled={props.submit.isLoading || status !== "address_verified"}
control={control} type="submit"
name="companyCode" variant="primary"
label="Company name" data-testid="avatax-configuration-save-button"
helperText={formState.errors.companyCode?.message} >
/> {props.submit.isLoading ? "Saving..." : "Save"}
<HelperText> </Button>
When not provided, the default company will be used.{" "}
<TextLink
newTab
href="https://developer.avalara.com/erp-integration-guide/sales-tax-badge/transactions/simple-transactions/company-codes/"
>
Read more
</TextLink>{" "}
about company codes.
</HelperText>
</div>
</Box> </Box>
<Box paddingY={4} display={"flex"} flexDirection={"column"} gap={10}> </form>
<AppToggle </FormProvider>
control={control}
label="Use sandbox mode"
helperText={
<HelperText>
Toggling between{" "}
<TextLink
href="https://developer.avalara.com/erp-integration-guide/sales-tax-badge/authentication-in-avatax/sandbox-vs-production/"
newTab
>
<q>Production</q> and <q>Sandbox</q>
</TextLink>{" "}
environment.
</HelperText>
}
name="isSandbox"
/>
<AppToggle
control={control}
label="Autocommit"
helperText={
<HelperText>
If enabled, the order will be automatically{" "}
<TextLink
href="https://developer.avalara.com/communications/dev-guide_rest_v2/commit-uncommit/"
newTab
>
commited to Avalara.
</TextLink>{" "}
</HelperText>
}
name="isAutocommit"
/>
</Box>
</Box>
<Divider marginY={8} />
<Text marginBottom={4} as="h3" variant="heading">
Address
</Text>
<Box paddingY={4} display={"grid"} gridTemplateColumns={2} gap={12}>
<Input
control={control}
required
name="address.street"
label="Street *"
helperText={formState.errors.address?.street?.message}
/>
<Input
control={control}
required
name="address.city"
label="City *"
helperText={formState.errors.address?.city?.message}
/>
<Input
control={control}
required
name="address.state"
label="State *"
helperText={formState.errors.address?.state?.message}
/>
<CountrySelect
control={control}
required
name="address.country"
label="Country *"
helperText={formState.errors.address?.country?.message}
/>
<Input
control={control}
required
name="address.zip"
label="Zip *"
helperText={formState.errors.address?.zip?.message}
/>
</Box>
<Divider marginY={8} />
<Text marginBottom={4} as="h3" variant="heading">
Tax codes
</Text>
<Box paddingY={4} display={"grid"} gridTemplateColumns={2} gap={12}>
<div>
<Input
control={control}
name="shippingTaxCode"
label="Shipping tax code"
helperText={formState.errors.shippingTaxCode?.message}
/>
<HelperText>
Tax code that for the shipping line sent to Avatax.{" "}
<TextLink newTab href="https://taxcode.avatax.avalara.com">
Must match Avatax tax codes format.
</TextLink>
</HelperText>
</div>
</Box>
<Divider marginY={8} />
<Box display={"flex"} justifyContent={"space-between"} alignItems={"center"}>
{props.leftButton}
<Button
disabled={props.isLoading}
type="submit"
variant="primary"
data-testid="avatax-configuration-save-button"
>
{props.isLoading ? "Saving..." : "Save"}
</Button>
</Box>
</form>
</AppCard> </AppCard>
); );
}; };

View file

@ -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<AvataxConfig>();
const { status } = useAvataxConfigurationStatus();
const disabled = status === "not_authenticated";
return (
<FormSection title="Tax codes" disabled={disabled}>
<div>
<Input
disabled={disabled}
control={control}
name="shippingTaxCode"
label="Shipping tax code"
helperText={formState.errors.shippingTaxCode?.message}
/>
<HelperText disabled={disabled}>
Tax code that for the shipping line sent to Avatax.{" "}
<TextLink newTab href="https://taxcode.avatax.avalara.com">
Must match Avatax tax codes format.
</TextLink>
</HelperText>
</div>
</FormSection>
);
};

View file

@ -8,11 +8,13 @@ export const AvataxInstructions = () => {
title="Avatax Configuration" title="Avatax Configuration"
description={ description={
<> <>
The form consists of two sections: <i>Credentials</i> and <i>Address</i>. <Text as="p" marginBottom={8}>
<br /> The form consists of two sections: <i>Credentials</i> and <i>Address</i>.
<br /> </Text>
<i>Credentials</i> will fail if: <Text as="p">
<Box as="ol" margin={0}> <i>Credentials</i> will fail if:
</Text>
<Box as="ol" marginBottom={1}>
<li> <li>
<Text>- The username or password are incorrect.</Text> <Text>- The username or password are incorrect.</Text>
</li> </li>
@ -23,11 +25,14 @@ export const AvataxInstructions = () => {
</Text> </Text>
</li> </li>
</Box> </Box>
<br /> <Text as="p" marginBottom={8}>
<br /> You must verify the credentials by clicking the <Text variant="bodyStrong">Verify</Text>{" "}
<i>Address</i> will fail if: button.
<br /> </Text>
<Box as="ol" margin={0}> <Text as="p">
<i>Address</i> will fail if:
</Text>
<Box as="ol" marginTop={1} marginBottom={2} marginX={1}>
<li> <li>
<Text> <Text>
- The address does not match{" "} - The address does not match{" "}
@ -38,13 +43,15 @@ export const AvataxInstructions = () => {
</Text> </Text>
</li> </li>
</Box> </Box>
<br /> <Text as="p" marginBottom={4}>
<br /> You must verify the address by clicking the <Text variant="bodyStrong">Verify</Text>{" "}
If the configuration fails, please visit the{" "} button.
<TextLink href="https://developer.avalara.com" newTab> </Text>
Avatax documentation <Text as="p" marginBottom={4}>
</TextLink> 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.
</Text>
</> </>
} }
/> />

View file

@ -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 };
};

View file

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { AvataxConfigurationForm } from "./avatax-configuration-form"; 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 { trpcClient } from "../../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared"; import { useDashboardNotification } from "@saleor/apps-shared";
import { useRouter } from "next/router"; 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( const submitHandler = React.useCallback(
(data: AvataxConfig) => { (data: AvataxConfig) => {
createMutation({ value: data }); createMutation({ value: data });
@ -32,10 +51,32 @@ export const CreateAvataxConfiguration = () => {
[createMutation] [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 ( return (
<AvataxConfigurationForm <AvataxConfigurationForm
isLoading={isCreateLoading} submit={submit}
onSubmit={submitHandler} validateAddress={validateAddress}
validateCredentials={validateCredentials}
defaultValues={defaultAvataxConfig} defaultValues={defaultAvataxConfig}
leftButton={ leftButton={
<Button <Button

View file

@ -4,13 +4,16 @@ import { useRouter } from "next/router";
import React from "react"; import React from "react";
import { z } from "zod"; import { z } from "zod";
import { trpcClient } from "../../trpc/trpc-client"; import { trpcClient } from "../../trpc/trpc-client";
import { AvataxConnectionObfuscator } from "../avatax-connection-obfuscator"; import { AvataxObfuscator } from "../avatax-obfuscator";
import { AvataxConfig } from "../avatax-connection-schema"; import { AvataxConfig, BaseAvataxConfig } from "../avatax-connection-schema";
import { AvataxConfigurationForm } from "./avatax-configuration-form"; import { AvataxConfigurationForm } from "./avatax-configuration-form";
import { useAvataxConfigurationStatus } from "./configuration-status";
const avataxObfuscator = new AvataxConnectionObfuscator(); const avataxObfuscator = new AvataxObfuscator();
export const EditAvataxConfiguration = () => { export const EditAvataxConfiguration = () => {
const { setStatus } = useAvataxConfigurationStatus();
const router = useRouter(); const router = useRouter();
const { id } = router.query; const { id } = router.query;
@ -19,6 +22,11 @@ export const EditAvataxConfiguration = () => {
const { refetch: refetchProvidersConfigurationData } = const { refetch: refetchProvidersConfigurationData } =
trpcClient.providersConfiguration.getAll.useQuery(); 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 { notifySuccess, notifyError } = useDashboardNotification();
const { mutate: patchMutation, isLoading: isPatchLoading } = const { mutate: patchMutation, isLoading: isPatchLoading } =
trpcClient.avataxConnection.update.useMutation({ trpcClient.avataxConnection.update.useMutation({
@ -59,6 +67,7 @@ export const EditAvataxConfiguration = () => {
const submitHandler = React.useCallback( const submitHandler = React.useCallback(
(data: AvataxConfig) => { (data: AvataxConfig) => {
patchMutation({ patchMutation({
// todo: remove obfuscation
value: avataxObfuscator.filterOutObfuscated(data), value: avataxObfuscator.filterOutObfuscated(data),
id: configurationId, 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) { if (isGetLoading) {
// todo: replace with skeleton once its available in Macaw // todo: replace with skeleton once its available in Macaw
return ( return (
@ -91,10 +140,12 @@ export const EditAvataxConfiguration = () => {
</Box> </Box>
); );
} }
return ( return (
<AvataxConfigurationForm <AvataxConfigurationForm
isLoading={isPatchLoading} submit={submit}
onSubmit={submitHandler} validateAddress={validateAddress}
validateCredentials={validateCredentials}
defaultValues={data.config} defaultValues={data.config}
leftButton={ leftButton={
<Button onClick={deleteHandler} variant="error" data-testid="delete-avatax-button"> <Button onClick={deleteHandler} variant="error" data-testid="delete-avatax-button">

View file

@ -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 (
<Text
color={disabled ? "textNeutralDisabled" : "textNeutralSubdued"}
fontWeight={"captionLarge"}
>
{children}
</Text>
);
};

View file

@ -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 (
<>
<Text
marginBottom={4}
as="h3"
variant="heading"
{...(disabled && { color: "textNeutralDisabled" })}
>
{title}
</Text>
{subtitle && (
<Text as="p" marginBottom={4} {...(disabled && { color: "textNeutralDisabled" })}>
{subtitle}
</Text>
)}
<Box display="grid" gridTemplateColumns={2} gap={12}>
{children}
</Box>
</>
);
};

View file

@ -9,11 +9,11 @@ export const providerConnectionsRouter = router({
name: "providerConnectionsRouter.getAll", name: "providerConnectionsRouter.getAll",
}); });
const items = await new PublicProviderConnectionsService( const items = await new PublicProviderConnectionsService({
ctx.apiClient, appId: ctx.appId!,
ctx.appId!, client: ctx.apiClient,
ctx.saleorApiUrl saleorApiUrl: ctx.saleorApiUrl,
).getAll(); }).getAll();
logger.info("Returning tax providers configuration"); logger.info("Returning tax providers configuration");

View file

@ -9,9 +9,25 @@ export class PublicProviderConnectionsService {
private avataxConnectionService: PublicAvataxConnectionService; private avataxConnectionService: PublicAvataxConnectionService;
private taxJarConnectionService: PublicTaxJarConnectionService; private taxJarConnectionService: PublicTaxJarConnectionService;
private logger: Logger; private logger: Logger;
constructor(client: Client, appId: string, saleorApiUrl: string) { constructor({
this.avataxConnectionService = new PublicAvataxConnectionService(client, appId, saleorApiUrl); client,
this.taxJarConnectionService = new PublicTaxJarConnectionService(client, appId, saleorApiUrl); 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({ this.logger = createLogger({
name: "PublicProviderConnectionsService", name: "PublicProviderConnectionsService",
metadataKey: TAX_PROVIDER_KEY, metadataKey: TAX_PROVIDER_KEY,

View file

@ -7,8 +7,16 @@ import { TaxJarConnectionService } from "./taxjar-connection.service";
export class PublicTaxJarConnectionService { export class PublicTaxJarConnectionService {
private readonly connectionService: TaxJarConnectionService; private readonly connectionService: TaxJarConnectionService;
private readonly obfuscator = new TaxJarConnectionObfuscator(); private readonly obfuscator = new TaxJarConnectionObfuscator();
constructor(client: Client, appId: string, saleorApiUrl: string) { constructor({
this.connectionService = new TaxJarConnectionService(client, appId, saleorApiUrl); client,
appId,
saleorApiUrl,
}: {
client: Client;
appId: string;
saleorApiUrl: string;
}) {
this.connectionService = new TaxJarConnectionService({ client, appId, saleorApiUrl });
this.obfuscator = new TaxJarConnectionObfuscator(); this.obfuscator = new TaxJarConnectionObfuscator();
} }

View file

@ -9,7 +9,15 @@ import { TaxJarValidationService } from "./taxjar-validation.service";
export class TaxJarConnectionService { export class TaxJarConnectionService {
private logger: Logger; private logger: Logger;
private taxJarConnectionRepository: TaxJarConnectionRepository; private taxJarConnectionRepository: TaxJarConnectionRepository;
constructor(client: Client, appId: string, saleorApiUrl: string) { constructor({
client,
appId,
saleorApiUrl,
}: {
client: Client;
appId: string;
saleorApiUrl: string;
}) {
this.logger = createLogger({ this.logger = createLogger({
name: "TaxJarConnectionService", name: "TaxJarConnectionService",
}); });

View file

@ -15,11 +15,11 @@ export const taxJarTaxCodesRouter = router({
name: "taxjarTaxCodesRouter.getAllForId", name: "taxjarTaxCodesRouter.getAllForId",
}); });
const connectionService = new TaxJarConnectionService( const connectionService = new TaxJarConnectionService({
ctx.apiClient, appId: ctx.appId!,
ctx.appId!, client: ctx.apiClient,
ctx.saleorApiUrl saleorApiUrl: ctx.saleorApiUrl,
); });
const connection = await connectionService.getById(input.connectionId); const connection = await connectionService.getById(input.connectionId);
const taxCodesService = new TaxJarTaxCodesService(connection.config); const taxCodesService = new TaxJarTaxCodesService(connection.config);

View file

@ -25,11 +25,11 @@ const postInputSchema = z.object({
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) => const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
next({ next({
ctx: { ctx: {
connectionService: new PublicTaxJarConnectionService( connectionService: new PublicTaxJarConnectionService({
ctx.apiClient, appId: ctx.appId!,
ctx.appId!, client: ctx.apiClient,
ctx.saleorApiUrl saleorApiUrl: ctx.saleorApiUrl,
), }),
}, },
}) })
); );

View file

@ -26,9 +26,9 @@ const Description = ({
<Text as="h3" variant="heading"> <Text as="h3" variant="heading">
{title} {title}
</Text> </Text>
<Text as="p" variant="body"> <Box fontWeight={"bodyMedium"} fontSize={"bodyMedium"}>
{description} {description}
</Text> </Box>
</Box> </Box>
); );
}; };

View file

@ -1,3 +1,4 @@
import { Provider } from "jotai";
import { AvataxInstructions } from "../../../modules/avatax/ui/avatax-instructions"; import { AvataxInstructions } from "../../../modules/avatax/ui/avatax-instructions";
import { EditAvataxConfiguration } from "../../../modules/avatax/ui/edit-avatax-configuration"; import { EditAvataxConfiguration } from "../../../modules/avatax/ui/edit-avatax-configuration";
import { AppColumns } from "../../../modules/ui/app-columns"; import { AppColumns } from "../../../modules/ui/app-columns";
@ -12,7 +13,9 @@ const EditAvataxPage = () => {
<main> <main>
<AppColumns top={<Header />}> <AppColumns top={<Header />}>
<AvataxInstructions /> <AvataxInstructions />
<EditAvataxConfiguration /> <Provider>
<EditAvataxConfiguration />
</Provider>
</AppColumns> </AppColumns>
</main> </main>
); );

View file

@ -1,7 +1,8 @@
import { Box, Text } from "@saleor/macaw-ui/next"; 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 { CreateAvataxConfiguration } from "../../../modules/avatax/ui/create-avatax-configuration";
import { AppColumns } from "../../../modules/ui/app-columns"; import { AppColumns } from "../../../modules/ui/app-columns";
import { AvataxInstructions } from "../../../modules/avatax/ui/avatax-instructions";
const Header = () => { const Header = () => {
return ( return (
@ -18,7 +19,9 @@ const NewAvataxPage = () => {
<main> <main>
<AppColumns top={<Header />}> <AppColumns top={<Header />}>
<AvataxInstructions /> <AvataxInstructions />
<CreateAvataxConfiguration /> <Provider>
<CreateAvataxConfiguration />
</Provider>
</AppColumns> </AppColumns>
</main> </main>
); );