refactor: 🚚 split avatax & taxjar into separate services (#264)

* refactor: ♻️ split into services

* docs: 💡 add comment about joining providers

* fix: 🐛 change name from avalara to avatax

* build: 👷 add changeset

* fix: 🐛 move return into catch
This commit is contained in:
Adrian Pilarczyk 2023-03-10 13:04:25 +01:00 committed by GitHub
parent 5e28fce12e
commit 56a4dbb3a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 937 additions and 441 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-taxes": patch
---
Extracts the tax providers into individual services. Fixes the issue with updating configs with obfuscated values.

View file

@ -3,3 +3,5 @@ const { randomUUID } = require("crypto"); // Added in: node v14.17.0
export const createId = (): string => randomUUID(); export const createId = (): string => randomUUID();
export const obfuscateSecret = (value: string) => value.replace(/.(?=.{4})/g, "*"); export const obfuscateSecret = (value: string) => value.replace(/.(?=.{4})/g, "*");
export const isObfuscated = (value: string) => value.includes("****");

View file

@ -1,11 +1,11 @@
import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel"; import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel";
import { LineItemModel } from "avatax/lib/models/LineItemModel"; import { LineItemModel } from "avatax/lib/models/LineItemModel";
import { TransactionModel } from "avatax/lib/models/TransactionModel"; import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { TaxBaseFragment } from "../../../../../generated/graphql"; import { TaxBaseFragment } from "../../../generated/graphql";
import { ChannelConfig } from "../../../channels-configuration/channels-config"; import { ChannelConfig } from "../channels-configuration/channels-config";
import { taxLineResolver } from "../../tax-line-resolver"; import { taxLineResolver } from "../taxes/tax-line-resolver";
import { ResponseTaxPayload } from "../../types"; import { ResponseTaxPayload } from "../taxes/types";
import { AvataxConfig } from "./avatax-config"; import { AvataxConfig } from "./avatax-config";
const SHIPPING_ITEM_CODE = "Shipping"; const SHIPPING_ITEM_CODE = "Shipping";

View file

@ -1,7 +1,7 @@
import Avatax from "avatax"; import Avatax from "avatax";
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 { logger } from "../../../../lib/logger"; import { logger } from "../../lib/logger";
import { AvataxConfig } from "./avatax-config"; import { AvataxConfig } from "./avatax-config";
type AvataxSettings = { type AvataxSettings = {
@ -55,6 +55,20 @@ export class AvataxClient {
} }
async ping() { async ping() {
return this.client.ping(); try {
const result = await this.client.ping();
return {
authenticated: result.authenticated,
...(!result.authenticated && {
error: "Avatax was not able to authenticate with the provided credentials.",
}),
};
} catch (error) {
return {
authenticated: false,
error: "Avatax was not able to authenticate with the provided credentials.",
};
}
} }
} }

View file

@ -0,0 +1,29 @@
import { z } from "zod";
export const avataxConfigSchema = z.object({
name: z.string().min(1, { message: "Name requires at least one character." }),
username: z.string().min(1, { message: "Username requires at least one character." }),
password: z.string().min(1, { message: "Password requires at least one character." }),
isSandbox: z.boolean(),
companyName: z.string().min(1, { message: "Company name requires at least one character." }),
isAutocommit: z.boolean(),
});
export type AvataxConfig = z.infer<typeof avataxConfigSchema>;
export const defaultAvataxConfig: AvataxConfig = {
name: "",
username: "",
password: "",
companyName: "",
isSandbox: true,
isAutocommit: false,
};
export const avataxInstanceConfigSchema = z.object({
id: z.string(),
provider: z.literal("avatax"),
config: avataxConfigSchema,
});
export type AvataxInstanceConfig = z.infer<typeof avataxInstanceConfigSchema>;

View file

@ -0,0 +1,116 @@
import { z } from "zod";
import { logger as pinoLogger } from "../../lib/logger";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server";
import { avataxConfigSchema } from "./avatax-config";
import { AvataxConfigurationService } from "./avatax-configuration.service";
const getInputSchema = z.object({
id: z.string(),
});
const deleteInputSchema = z.object({
id: z.string(),
});
const patchInputSchema = z.object({
id: z.string(),
value: avataxConfigSchema.partial(),
});
const putInputSchema = z.object({
id: z.string(),
value: avataxConfigSchema,
});
const postInputSchema = z.object({
value: avataxConfigSchema,
});
export const avataxConfigurationRouter = router({
get: protectedClientProcedure.input(getInputSchema).query(async ({ ctx, input }) => {
const logger = pinoLogger.child({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "avataxConfigurationRouter.get",
});
logger.debug("avataxConfigurationRouter.get called");
const { apiClient, saleorApiUrl } = ctx;
const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl);
const result = await avataxConfigurationService.get(input.id);
logger.debug({ result }, "avataxConfigurationRouter.get finished");
return result;
}),
post: protectedClientProcedure.input(postInputSchema).mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "avataxConfigurationRouter.post",
});
logger.debug("avataxConfigurationRouter.post called");
const { apiClient, saleorApiUrl } = ctx;
const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl);
const result = await avataxConfigurationService.post(input.value);
logger.debug({ result }, "avataxConfigurationRouter.post finished");
return result;
}),
delete: protectedClientProcedure.input(deleteInputSchema).mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "avataxConfigurationRouter.delete",
});
logger.debug("avataxConfigurationRouter.delete called");
const { apiClient, saleorApiUrl } = ctx;
const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl);
const result = await avataxConfigurationService.delete(input.id);
logger.debug({ result }, "avataxConfigurationRouter.delete finished");
return result;
}),
patch: protectedClientProcedure.input(patchInputSchema).mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "avataxConfigurationRouter.patch",
});
logger.debug("avataxConfigurationRouter.patch called");
const { apiClient, saleorApiUrl } = ctx;
const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl);
const result = await avataxConfigurationService.patch(input.id, input.value);
logger.debug({ result }, "avataxConfigurationRouter.patch finished");
return result;
}),
put: protectedClientProcedure.input(putInputSchema).mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "avataxConfigurationRouter.put",
});
logger.debug("avataxConfigurationRouter.put called");
const { apiClient, saleorApiUrl } = ctx;
const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl);
const result = await avataxConfigurationService.put(input.id, input.value);
logger.debug({ result }, "avataxConfigurationRouter.put finished");
return result;
}),
});

View file

@ -0,0 +1,151 @@
import pino from "pino";
import { Client } from "urql";
import { createLogger } from "../../lib/logger";
import { isObfuscated, obfuscateSecret } from "../../lib/utils";
import { createSettingsManager } from "../app-configuration/metadata-manager";
import { CrudSettingsConfigurator } from "../crud-settings/crud-settings.service";
import { providersSchema } from "../providers-configuration/providers-config";
import { TAX_PROVIDER_KEY } from "../providers-configuration/providers-configuration-service";
import { AvataxClient } from "./avatax-client";
import {
AvataxConfig,
avataxConfigSchema,
AvataxInstanceConfig,
avataxInstanceConfigSchema,
} from "./avatax-config";
const obfuscateConfig = (config: AvataxConfig) => ({
...config,
username: obfuscateSecret(config.username),
password: obfuscateSecret(config.password),
});
const obfuscateProvidersConfig = (instances: AvataxInstanceConfig[]) =>
instances.map((instance) => ({
...instance,
config: obfuscateConfig(instance.config),
}));
const getSchema = avataxInstanceConfigSchema.transform((instance) => ({
...instance,
config: obfuscateConfig(instance.config),
}));
const patchSchema = avataxConfigSchema.partial().transform((c) => {
const { username, password, ...config } = c ?? {};
return {
...config,
...(username && !isObfuscated(username) && { username }),
...(password && !isObfuscated(password) && { password }),
};
});
const putSchema = avataxConfigSchema.transform((c) => {
const { username, password, ...config } = c;
return {
...config,
...(!isObfuscated(username) && { username }),
...(!isObfuscated(password) && { password }),
};
});
export class AvataxConfigurationService {
private crudSettingsConfigurator: CrudSettingsConfigurator;
private logger: pino.Logger;
constructor(client: Client, saleorApiUrl: string) {
const settingsManager = createSettingsManager(client);
this.crudSettingsConfigurator = new CrudSettingsConfigurator(
settingsManager,
saleorApiUrl,
TAX_PROVIDER_KEY
);
this.logger = createLogger({
service: "AvataxConfigurationService",
metadataKey: TAX_PROVIDER_KEY,
});
}
async getAll() {
this.logger.debug(".getAll called");
const { data } = await this.crudSettingsConfigurator.readAll();
const validation = providersSchema.safeParse(data);
if (!validation.success) {
this.logger.error({ error: validation.error.format() }, "Validation error while getAll");
throw new Error(validation.error.message);
}
const instances = validation.data.filter(
(instance) => instance.provider === "avatax"
) as AvataxInstanceConfig[];
return obfuscateProvidersConfig(instances);
}
async get(id: string) {
this.logger.debug(`.get called with id: ${id}`);
const { data } = await this.crudSettingsConfigurator.read(id);
this.logger.debug({ setting: data }, `Fetched setting from crudSettingsConfigurator`);
const validation = getSchema.safeParse(data);
if (!validation.success) {
this.logger.error({ error: validation.error.format() }, "Validation error while get");
throw new Error(validation.error.message);
}
return validation.data;
}
async post(config: AvataxConfig) {
this.logger.debug(`.post called with value: ${JSON.stringify(config)}`);
const avataxClient = new AvataxClient(config);
const validation = await avataxClient.ping();
if (!validation.authenticated) {
this.logger.error(validation.error);
throw new Error(validation.error);
}
}
async patch(id: string, config: Partial<AvataxConfig>) {
this.logger.debug(`.patch called with id: ${id} and value: ${JSON.stringify(config)}`);
const result = await this.get(id);
// omit the key "id" from the result
const { id: _, ...setting } = result;
const validation = patchSchema.safeParse(config);
if (!validation.success) {
this.logger.error({ error: validation.error.format() }, "Validation error while patch");
throw new Error(validation.error.message);
}
return this.crudSettingsConfigurator.update(id, {
...setting,
config: { ...setting.config, ...validation.data },
});
}
async put(id: string, config: AvataxConfig) {
const result = await this.get(id);
// omit the key "id" from the result
const { id: _, ...setting } = result;
const validation = putSchema.safeParse(config);
if (!validation.success) {
this.logger.error({ error: validation.error.format() }, "Validation error while patch");
throw new Error(validation.error.message);
}
this.logger.debug(`.put called with id: ${id} and value: ${JSON.stringify(config)}`);
return this.crudSettingsConfigurator.update(id, {
...setting,
config: { ...validation.data },
});
}
async delete(id: string) {
this.logger.debug(`.delete called with id: ${id}`);
return this.crudSettingsConfigurator.delete(id);
}
}

View file

@ -1,7 +1,7 @@
import { TaxBaseFragment } from "../../../../../generated/graphql"; import { TaxBaseFragment } from "../../../generated/graphql";
import { logger } from "../../../../lib/logger"; import { logger } from "../../lib/logger";
import { ChannelConfig } from "../../../channels-configuration/channels-config"; import { ChannelConfig } from "../channels-configuration/channels-config";
import { TaxProvider } from "../../tax-provider"; import { TaxProvider } from "../taxes/tax-provider";
import { avataxCalculate } from "./avatax-calculate"; import { avataxCalculate } from "./avatax-calculate";
import { AvataxClient } from "./avatax-client"; import { AvataxClient } from "./avatax-client";
import { AvataxConfig, defaultAvataxConfig } from "./avatax-config"; import { AvataxConfig, defaultAvataxConfig } from "./avatax-config";
@ -19,24 +19,6 @@ export class AvataxProvider implements TaxProvider {
this.client = avataxClient; this.client = avataxClient;
} }
async validate() {
logger.info("Avatax validate");
const validation = await this.client.ping();
logger.info(validation, "Avatax ping result");
if (validation.authenticated) {
return {
ok: true,
};
}
return {
ok: false,
error:
"Avalara was unable to authenticate. Check if the username and password you provided are correct.",
};
}
async calculate(payload: TaxBaseFragment, channel: ChannelConfig) { async calculate(payload: TaxBaseFragment, channel: ChannelConfig) {
logger.info("Avatax calculate"); logger.info("Avatax calculate");
const model = avataxCalculate.preparePayload(payload, channel, this.config); const model = avataxCalculate.preparePayload(payload, channel, this.config);

View file

@ -13,10 +13,10 @@ import { Button, makeStyles } from "@saleor/macaw-ui";
import React from "react"; import React from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { trpcClient } from "../../../../trpc/trpc-client"; import { trpcClient } from "../../trpc/trpc-client";
import { AppLink } from "../../../../ui/app-link"; import { AppLink } from "../../ui/app-link";
import { useInstanceId } from "../../../tax-context"; import { useInstanceId } from "../../taxes/tax-context";
import { avataxInstanceConfigSchema } from "../avatax-config"; import { avataxConfigSchema, avataxInstanceConfigSchema } from "../avatax-config";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
reverseRow: { reverseRow: {
@ -26,17 +26,15 @@ const useStyles = makeStyles((theme) => ({
}, },
})); }));
const schema = avataxInstanceConfigSchema.omit({ provider: true }); const schema = avataxConfigSchema;
type FormValues = z.infer<typeof schema>; type FormValues = z.infer<typeof schema>;
const defaultValues: FormValues = { const defaultValues: FormValues = {
config: { companyName: "",
companyName: "", isAutocommit: false,
isAutocommit: false, isSandbox: false,
isSandbox: false, password: "",
password: "", username: "",
username: "",
},
name: "", name: "",
}; };
@ -50,11 +48,31 @@ export const AvataxConfigurationForm = () => {
}); });
const { instanceId, setInstanceId } = useInstanceId(); const { instanceId, setInstanceId } = useInstanceId();
const { refetch: refetchChannelConfigurationData } = const { refetch: refetchChannelConfigurationData } =
trpcClient.channelsConfiguration.fetch.useQuery(); trpcClient.channelsConfiguration.fetch.useQuery(undefined, {
const { data: providersConfigurationData, refetch: refetchProvidersConfigurationData } = onError(error) {
trpcClient.providersConfiguration.getAll.useQuery(); appBridge?.dispatch(
actions.Notification({
title: "Error",
text: error.message,
status: "error",
})
);
},
});
const { data: providersConfig, refetch: refetchProvidersConfigurationData } =
trpcClient.providersConfiguration.getAll.useQuery(undefined, {
onError(error) {
appBridge?.dispatch(
actions.Notification({
title: "Error",
text: error.message,
status: "error",
})
);
},
});
const instance = providersConfigurationData?.find((instance) => instance.id === instanceId); const instance = providersConfig?.find((instance) => instance.id === instanceId);
const resetInstanceId = () => { const resetInstanceId = () => {
setInstanceId(null); setInstanceId(null);
@ -62,16 +80,16 @@ export const AvataxConfigurationForm = () => {
React.useEffect(() => { React.useEffect(() => {
if (instance) { if (instance) {
const { provider, id, ...values } = instance; const { config } = instance;
reset(values); reset(config);
} else { } else {
reset(defaultValues); reset(defaultValues);
} }
}, [instance, reset]); }, [instance, reset]);
const { mutate: createMutation, isLoading: isCreateLoading } = const { mutate: createMutation, isLoading: isCreateLoading } =
trpcClient.providersConfiguration.create.useMutation({ trpcClient.avataxConfiguration.post.useMutation({
onSuccess({ id }) { onSuccess({ data: { id } }) {
setInstanceId(id); setInstanceId(id);
refetchProvidersConfigurationData(); refetchProvidersConfigurationData();
appBridge?.dispatch( appBridge?.dispatch(
@ -94,7 +112,7 @@ export const AvataxConfigurationForm = () => {
}); });
const { mutate: updateMutation, isLoading: isUpdateLoading } = const { mutate: updateMutation, isLoading: isUpdateLoading } =
trpcClient.providersConfiguration.update.useMutation({ trpcClient.avataxConfiguration.patch.useMutation({
onSuccess() { onSuccess() {
refetchProvidersConfigurationData(); refetchProvidersConfigurationData();
appBridge?.dispatch( appBridge?.dispatch(
@ -116,7 +134,7 @@ export const AvataxConfigurationForm = () => {
}, },
}); });
const { mutate: deleteMutation } = trpcClient.providersConfiguration.delete.useMutation({ const { mutate: deleteMutation } = trpcClient.avataxConfiguration.delete.useMutation({
onSuccess() { onSuccess() {
resetInstanceId(); resetInstanceId();
refetchProvidersConfigurationData(); refetchProvidersConfigurationData();
@ -144,21 +162,15 @@ export const AvataxConfigurationForm = () => {
fullWidth: true, fullWidth: true,
}; };
const onSubmit = (values: FormValues) => { const onSubmit = (value: FormValues) => {
if (instanceId) { if (instanceId) {
updateMutation({ updateMutation({
id: instanceId, id: instanceId,
provider: { value,
...values,
provider: "avatax",
},
}); });
} else { } else {
createMutation({ createMutation({
provider: { value,
...values,
provider: "avatax",
},
}); });
} }
}; };
@ -201,9 +213,9 @@ export const AvataxConfigurationForm = () => {
<InputLabel> <InputLabel>
Sandbox Sandbox
<Controller <Controller
name={"config.isSandbox"} name={"isSandbox"}
control={control} control={control}
defaultValue={defaultValues.config.isSandbox} defaultValue={defaultValues.isSandbox}
render={({ field }) => ( render={({ field }) => (
<Switch <Switch
{...field} {...field}
@ -229,9 +241,9 @@ export const AvataxConfigurationForm = () => {
<InputLabel> <InputLabel>
Autocommit Autocommit
<Controller <Controller
name={"config.isAutocommit"} name={"isAutocommit"}
control={control} control={control}
defaultValue={defaultValues.config.isAutocommit} defaultValue={defaultValues.isAutocommit}
render={({ field }) => ( render={({ field }) => (
<Switch <Switch
{...field} {...field}
@ -254,39 +266,39 @@ export const AvataxConfigurationForm = () => {
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<Controller <Controller
name="config.username" name="username"
control={control} control={control}
defaultValue="" defaultValue=""
render={({ field }) => ( render={({ field }) => (
<TextField type="text" {...field} label="Username" {...textFieldProps} /> <TextField type="text" {...field} label="Username" {...textFieldProps} />
)} )}
/> />
{formState.errors.config?.username && ( {formState.errors.username && (
<FormHelperText error>{formState.errors.config.username.message}</FormHelperText> <FormHelperText error>{formState.errors.username.message}</FormHelperText>
)} )}
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<Controller <Controller
name="config.password" name="password"
control={control} control={control}
defaultValue={defaultValues.config.password} defaultValue={defaultValues.password}
render={({ field }) => <TextField label="Password" {...field} {...textFieldProps} />} render={({ field }) => <TextField label="Password" {...field} {...textFieldProps} />}
/> />
{formState.errors.config?.password && ( {formState.errors.password && (
<FormHelperText error>{formState.errors.config.password.message}</FormHelperText> <FormHelperText error>{formState.errors.password.message}</FormHelperText>
)} )}
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<Controller <Controller
name="config.companyName" name="companyName"
control={control} control={control}
defaultValue={defaultValues.config.companyName} defaultValue={defaultValues.companyName}
render={({ field }) => ( render={({ field }) => (
<TextField type="text" {...field} label="Company name" {...textFieldProps} /> <TextField type="text" {...field} label="Company name" {...textFieldProps} />
)} )}
/> />
{formState.errors.config?.companyName && ( {formState.errors.companyName && (
<FormHelperText error>{formState.errors.config.companyName.message}</FormHelperText> <FormHelperText error>{formState.errors.companyName.message}</FormHelperText>
)} )}
</Grid> </Grid>
</Grid> </Grid>

View file

@ -3,7 +3,7 @@ import { AvataxConfigurationForm } from "./avatax-configuration-form";
export const AvataxConfiguration = () => { export const AvataxConfiguration = () => {
return ( return (
<section> <section>
<h2>Avalara configuration</h2> <h2>Avatax configuration</h2>
<AvataxConfigurationForm /> <AvataxConfigurationForm />
</section> </section>
); );

View file

@ -78,9 +78,32 @@ export const ChannelTaxProviderForm = () => {
const { channelSlug } = useChannelSlug(); const { channelSlug } = useChannelSlug();
const { data: channelConfigurationData, refetch: refetchChannelConfigurationData } = const { data: channelConfigurationData, refetch: refetchChannelConfigurationData } =
trpcClient.channelsConfiguration.fetch.useQuery(); trpcClient.channelsConfiguration.fetch.useQuery(undefined, {
onError(error) {
appBridge?.dispatch(
actions.Notification({
title: "Error",
text: error.message,
status: "error",
})
);
},
});
const { data: providerInstances = [] } = trpcClient.providersConfiguration.getAll.useQuery(); const { data: providerInstances = [] } = trpcClient.providersConfiguration.getAll.useQuery(
undefined,
{
onError(error) {
appBridge?.dispatch(
actions.Notification({
title: "Error",
text: error.message,
status: "error",
})
);
},
}
);
const channelConfig = channelConfigurationData?.[channelSlug]; const channelConfig = channelConfigurationData?.[channelSlug];
const { mutate, isLoading } = trpcClient.channelsConfiguration.upsert.useMutation({ const { mutate, isLoading } = trpcClient.channelsConfiguration.upsert.useMutation({
@ -136,10 +159,10 @@ export const ChannelTaxProviderForm = () => {
defaultValue={""} defaultValue={""}
render={({ field }) => ( render={({ field }) => (
<Select fullWidth {...field}> <Select fullWidth {...field}>
{providerInstances.map(({ name, id, provider }) => ( {providerInstances.map(({ config, id, provider }) => (
<MenuItem value={id} key={name}> <MenuItem value={id} key={id}>
<div className={styles.menuItem}> <div className={styles.menuItem}>
<Typography variant="body1">{name}</Typography> <Typography variant="body1">{config.name}</Typography>
<ProviderIcon size={"medium"} provider={provider} /> <ProviderIcon size={"medium"} provider={provider} />
</div> </div>
</MenuItem> </MenuItem>

View file

@ -0,0 +1,109 @@
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import pino from "pino";
import { z } from "zod";
import { createLogger } from "../../lib/logger";
import { createId } from "../../lib/utils";
const settingsSchema = z.array(z.record(z.any()));
export class CrudSettingsConfigurator {
private logger: pino.Logger;
constructor(
private metadataManager: SettingsManager,
private saleorApiUrl: string,
private metadataKey: string
) {
this.metadataKey = metadataKey;
this.logger = createLogger({ service: "CrudSettingsConfigurator", metadataKey });
}
async readAll() {
this.logger.debug(".readAll called");
const result = await this.metadataManager.get(this.metadataKey, this.saleorApiUrl);
if (!result) {
this.logger.debug("No metadata found");
return { data: [] };
}
const data = JSON.parse(result);
const validation = settingsSchema.safeParse(data);
if (!validation.success) {
this.logger.error({ error: validation.error }, "Error while validating metadata");
throw new Error("Error while validating metadata");
}
return {
data: validation.data,
};
}
async read(id: string) {
this.logger.debug(".read called");
const result = await this.readAll();
const { data: settings } = result;
const item = settings.find((item) => item.id === id);
if (!item) {
this.logger.error({ id }, "Item not found");
throw new Error("Item not found");
}
return {
data: item,
};
}
async create(data: any) {
this.logger.debug(data, ".create called with:");
const getResponse = await this.readAll();
const prevData = getResponse.data;
const id = createId();
const newData = [...prevData, { ...data, id }];
await this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(newData),
domain: this.saleorApiUrl,
});
return {
data: { id },
};
}
async delete(id: string) {
this.logger.debug(`.delete called with: ${id}`);
const getResponse = await this.readAll();
const prevData = getResponse.data;
const nextData = prevData.filter((item) => item.id !== id);
await this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(nextData),
domain: this.saleorApiUrl,
});
}
async update(id: string, data: any) {
this.logger.debug(data, `.update called with: ${id}`);
const getResponse = await this.readAll();
const prevData = getResponse.data;
const nextData = prevData.map((item) => {
if (item.id === id) {
return { id, data };
}
return item;
});
await this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(nextData),
domain: this.saleorApiUrl,
});
}
}

View file

@ -1,43 +0,0 @@
import { Client } from "urql";
import { logger as pinoLogger } from "../../lib/logger";
import { createSettingsManager } from "../app-configuration/metadata-manager";
import { createDefaultConfig, serverProvidersSchema } from "./providers-config";
import { TaxProvidersConfigurator } from "./providers-configurator";
export class GetProvidersConfigurationService {
constructor(
private settings: {
apiClient: Client;
saleorApiUrl: string;
}
) {}
async getConfiguration() {
const logger = pinoLogger.child({
service: "GetProvidersConfigurationService",
saleorApiUrl: this.settings.saleorApiUrl,
});
const { saleorApiUrl, apiClient } = this.settings;
const taxConfigurator = new TaxProvidersConfigurator(
createSettingsManager(apiClient),
saleorApiUrl
);
const savedProvidersConfig = (await taxConfigurator.getConfig()) ?? null;
const validation = serverProvidersSchema.safeParse(savedProvidersConfig);
logger.info({ validation }, "Config validated:");
if (validation.success) {
logger.info("App config is valid. Returning.");
return validation.data;
}
logger.info("App config not found in metadata. Will return default config.");
const defaultConfig = createDefaultConfig();
return defaultConfig;
}
}

View file

@ -1,24 +1,9 @@
import { z } from "zod"; import { z } from "zod";
import { import { avataxInstanceConfigSchema } from "../avatax/avatax-config";
avataxInstanceConfigSchema, import { taxJarInstanceConfigSchema } from "../taxjar/taxjar-config";
serverAvataxSchema,
} from "../taxes/providers/avatax/avatax-config";
import {
taxJarInstanceConfigSchema,
serverTaxJarSchema,
} from "../taxes/providers/taxjar/taxjar-config";
export const providerSchema = taxJarInstanceConfigSchema.or(avataxInstanceConfigSchema); export const providerSchema = taxJarInstanceConfigSchema.or(avataxInstanceConfigSchema);
export const providersSchema = z.array(providerSchema.and(z.object({ id: z.string() }))); export const providersSchema = z.array(providerSchema);
const serverProviderSchema = serverTaxJarSchema.or(serverAvataxSchema);
export const serverProvidersSchema = z.array(
serverProviderSchema.and(z.object({ id: z.string() }))
);
export type ProvidersConfig = z.infer<typeof providersSchema>; export type ProvidersConfig = z.infer<typeof providersSchema>;
export type ProviderConfig = z.infer<typeof providerSchema>; export type ProviderConfig = z.infer<typeof providerSchema>;
export const defaultTaxProvidersConfig: ProvidersConfig = [];
export const createDefaultConfig = () => defaultTaxProvidersConfig;

View file

@ -0,0 +1,29 @@
import pino from "pino";
import { Client } from "urql";
import { createLogger } from "../../lib/logger";
import { AvataxConfigurationService } from "../avatax/avatax-configuration.service";
import { TaxJarConfigurationService } from "../taxjar/taxjar-configuration.service";
export const TAX_PROVIDER_KEY = "tax-providers";
export class TaxProvidersConfigurationService {
private avataxConfigurationService: AvataxConfigurationService;
private taxJarConfigurationService: TaxJarConfigurationService;
private logger: pino.Logger;
constructor(client: Client, saleorApiUrl: string) {
this.avataxConfigurationService = new AvataxConfigurationService(client, saleorApiUrl);
this.taxJarConfigurationService = new TaxJarConfigurationService(client, saleorApiUrl);
this.logger = createLogger({
service: "TaxProvidersConfigurationService",
metadataKey: TAX_PROVIDER_KEY,
});
}
async getAll() {
this.logger.debug(".getAll called");
const taxJar = await this.taxJarConfigurationService.getAll();
const avatax = await this.avataxConfigurationService.getAll();
// todo: add more clever way of joining the two. Maybe add updated_at date to the config and use it to sort?
return [...taxJar, ...avatax];
}
}

View file

@ -1,18 +1,7 @@
import { TRPCError } from "@trpc/server";
import { logger as pinoLogger } from "../../lib/logger"; import { logger as pinoLogger } from "../../lib/logger";
import { createId } from "../../lib/utils";
import { createSettingsManager } from "../app-configuration/metadata-manager";
import { ActiveTaxProvider } from "../taxes/active-tax-provider";
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 { GetProvidersConfigurationService } from "./get-providers-configuration.service"; import { TaxProvidersConfigurationService } from "./providers-configuration-service";
import { ProvidersConfig } from "./providers-config";
import {
createProviderInstanceInputSchema,
deleteProviderInstanceInputSchema,
updateProviderInstanceInputSchema,
} from "./providers-config-input-schema";
import { TaxProvidersConfigurator } from "./providers-configurator";
export const providersConfigurationRouter = router({ export const providersConfigurationRouter = router({
getAll: protectedClientProcedure.query(async ({ ctx }) => { getAll: protectedClientProcedure.query(async ({ ctx }) => {
@ -20,128 +9,6 @@ export const providersConfigurationRouter = router({
logger.debug("providersConfigurationRouter.fetch called"); logger.debug("providersConfigurationRouter.fetch called");
return new GetProvidersConfigurationService({ return new TaxProvidersConfigurationService(ctx.apiClient, ctx.saleorApiUrl).getAll();
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
}), }),
update: protectedClientProcedure
.input(updateProviderInstanceInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.info(input, "providersConfigurationRouter.update called with input:");
const currentProviders = await new GetProvidersConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
const provider = currentProviders.find((provider) => provider.id === input.id);
if (provider) {
const taxProvider = new ActiveTaxProvider(provider);
const validation = await taxProvider.validate();
if (validation && !validation.ok) {
logger.error(validation.error, "External validation failed.");
throw new TRPCError({
code: "BAD_REQUEST",
message: validation.error,
});
}
}
logger.info(currentProviders, "Fetched current providers:");
const taxProvidersConfigurator = new TaxProvidersConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const nextProviders: ProvidersConfig = currentProviders.map((provider) => {
if (provider.id === input.id) {
return {
...input.provider,
id: input.id,
};
}
return provider;
});
logger.info(nextProviders, "Will update providers with the following value:");
await taxProvidersConfigurator.setConfig(nextProviders);
return null;
}),
delete: protectedClientProcedure
.input(deleteProviderInstanceInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.info(input, "providersConfigurationRouter.delete called with input:");
const currentProviders = await new GetProvidersConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
logger.info(currentProviders, "Fetched current providers:");
const taxProvidersConfigurator = new TaxProvidersConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const nextProviders: ProvidersConfig = currentProviders.filter(
(provider) => provider.id !== input.id
);
logger.info(nextProviders, "Will update providers with the following value:");
await taxProvidersConfigurator.setConfig(nextProviders);
return null;
}),
create: protectedClientProcedure
.input(createProviderInstanceInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.info(input, "providersConfigurationRouter.create called with input:");
const currentProviders = await new GetProvidersConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
logger.info(currentProviders, "Fetched current providers:");
const taxProvidersConfigurator = new TaxProvidersConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const id = createId();
const provider = { ...input.provider, id };
const nextProviders: ProvidersConfig = [...currentProviders, provider];
if (provider) {
const taxProvider = new ActiveTaxProvider(provider);
const validation = await taxProvider.validate();
if (validation && !validation.ok) {
logger.error(validation.error, "External validation failed.");
throw new TRPCError({
code: "BAD_REQUEST",
message: validation.error,
});
}
}
logger.info(nextProviders, "Will update providers with the following value:");
await taxProvidersConfigurator.setConfig(nextProviders);
return { id };
}),
}); });

View file

@ -1,9 +0,0 @@
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { PrivateMetadataAppConfigurator } from "../app-configuration/app-configurator";
import { ProvidersConfig } from "./providers-config";
export class TaxProvidersConfigurator extends PrivateMetadataAppConfigurator<ProvidersConfig> {
constructor(metadataManager: SettingsManager, saleorApiUrl: string) {
super(metadataManager, saleorApiUrl, "tax-providers");
}
}

View file

@ -1,9 +1,9 @@
import { FormControlLabel, Grid, Radio, RadioGroup, Typography } from "@material-ui/core"; import { FormControlLabel, Grid, Radio, RadioGroup, Typography } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui"; import { makeStyles } from "@saleor/macaw-ui";
import React from "react"; import React from "react";
import { AvataxConfiguration } from "../../taxes/providers/avatax/ui/avatax-configuration"; import { AvataxConfiguration } from "../../avatax/ui/avatax-configuration";
import { providerConfig, TaxProviderName } from "../../taxes/providers/config"; import { providerConfig, TaxProviderName } from "../../taxes/providers/config";
import { TaxJarConfiguration } from "../../taxes/providers/taxjar/ui/taxjar-configuration"; import { TaxJarConfiguration } from "../../taxjar/ui/taxjar-configuration";
import { useInstanceId } from "../../taxes/tax-context"; import { useInstanceId } from "../../taxes/tax-context";
import { trpcClient } from "../../trpc/trpc-client"; import { trpcClient } from "../../trpc/trpc-client";
import { AppPaper } from "../../ui/app-paper"; import { AppPaper } from "../../ui/app-paper";

View file

@ -58,7 +58,7 @@ export const TaxProvidersInstancesList = () => {
key={instance.id} key={instance.id}
> >
<OffsettedListItemCell className={styles.cell}> <OffsettedListItemCell className={styles.cell}>
{instance.name} {instance.config.name}
<ProviderIcon size="medium" provider={instance.provider} /> <ProviderIcon size="medium" provider={instance.provider} />
</OffsettedListItemCell> </OffsettedListItemCell>
</OffsettedListItem> </OffsettedListItem>

View file

@ -2,8 +2,8 @@ import { TaxBaseFragment } from "../../../generated/graphql";
import { createLogger } from "../../lib/logger"; import { createLogger } from "../../lib/logger";
import { ChannelConfig } from "../channels-configuration/channels-config"; import { ChannelConfig } from "../channels-configuration/channels-config";
import { ProviderConfig } from "../providers-configuration/providers-config"; import { ProviderConfig } from "../providers-configuration/providers-config";
import { AvataxProvider } from "./providers/avatax/avatax-provider"; import { AvataxProvider } from "../avatax/avatax-provider";
import { TaxJarProvider } from "./providers/taxjar/taxjar-provider"; import { TaxJarProvider } from "../taxjar/taxjar-provider";
import { TaxProvider } from "./tax-provider"; import { TaxProvider } from "./tax-provider";
import { TaxProviderError } from "./tax-provider-error"; import { TaxProviderError } from "./tax-provider-error";
@ -36,8 +36,4 @@ export class ActiveTaxProvider {
async calculate(payload: TaxBaseFragment, channel: ChannelConfig) { async calculate(payload: TaxBaseFragment, channel: ChannelConfig) {
return this.client.calculate(payload, channel); return this.client.calculate(payload, channel);
} }
async validate() {
return this.client.validate?.();
}
} }

View file

@ -1,39 +0,0 @@
import { z } from "zod";
import { obfuscateSecret } from "../../../../lib/utils";
import { createProviderInstanceSchema } from "../../tax-common-schema";
const avataxConfigSchema = z.object({
username: z.string().min(1, { message: "Username requires at least one character." }),
password: z.string().min(1, { message: "Password requires at least one character." }),
isSandbox: z.boolean(),
companyName: z.string().min(1, { message: "Company name requires at least one character." }),
isAutocommit: z.boolean(),
});
export type AvataxConfig = z.infer<typeof avataxConfigSchema>;
export const defaultAvataxConfig: AvataxConfig = {
username: "",
password: "",
companyName: "",
isSandbox: true,
isAutocommit: false,
};
export const avataxInstanceConfigSchema = createProviderInstanceSchema(
"avatax",
avataxConfigSchema
);
const transformedAvataxConfigSchema = avataxConfigSchema.transform((config) => {
return {
...config,
username: obfuscateSecret(config.username),
password: obfuscateSecret(config.username),
};
});
export const serverAvataxSchema = createProviderInstanceSchema(
"avatax",
transformedAvataxConfigSchema
);

View file

@ -6,7 +6,7 @@ export const providerConfig = {
icon: TaxJarIcon, icon: TaxJarIcon,
}, },
avatax: { avatax: {
label: "Avalara", label: "Avatax",
icon: AvataxIcon, icon: AvataxIcon,
}, },
}; };

View file

@ -1,31 +0,0 @@
import { z } from "zod";
import { obfuscateSecret } from "../../../../lib/utils";
import { createProviderInstanceSchema } from "../../tax-common-schema";
export const taxJarConfigSchema = z.object({
apiKey: z.string().min(1, { message: "API Key requires at least one character." }),
isSandbox: z.boolean(),
});
export type TaxJarConfig = z.infer<typeof taxJarConfigSchema>;
export const defaultTaxJarConfig: TaxJarConfig = {
apiKey: "",
isSandbox: false,
};
export const taxJarInstanceConfigSchema = createProviderInstanceSchema(
"taxjar",
taxJarConfigSchema
);
const transformedTaxJarConfigSchema = taxJarConfigSchema.transform((config) => {
return {
...config,
apiKey: obfuscateSecret(config.apiKey),
};
});
export const serverTaxJarSchema = createProviderInstanceSchema(
"taxjar",
transformedTaxJarConfigSchema
);

View file

@ -1,5 +1,4 @@
import { z, ZodTypeAny } from "zod"; import { z } from "zod";
import { TaxProviderName } from "./providers/config";
export const addressSchema = z.object({ export const addressSchema = z.object({
country: z.string(), country: z.string(),
@ -8,19 +7,3 @@ export const addressSchema = z.object({
city: z.string(), city: z.string(),
street: z.string(), street: z.string(),
}); });
const baseProviderInstanceSchema = z.object({
name: z.string().min(1),
});
export const createProviderInstanceSchema = <
TConfig extends ZodTypeAny,
TProvider extends TaxProviderName
>(
provider: TProvider,
schema: TConfig
) =>
baseProviderInstanceSchema.extend({
provider: z.literal(provider),
config: schema,
});

View file

@ -8,5 +8,4 @@ type ExternalValidationResult = { ok: boolean; error?: string };
export interface TaxProvider { export interface TaxProvider {
name: TaxProviderName; name: TaxProviderName;
calculate: (payload: TaxBaseFragment, channel: ChannelConfig) => Promise<ResponseTaxPayload>; calculate: (payload: TaxBaseFragment, channel: ChannelConfig) => Promise<ResponseTaxPayload>;
validate?: () => Promise<ExternalValidationResult>;
} }

View file

@ -4,10 +4,10 @@ import {
TaxBaseFragment, TaxBaseFragment,
TaxBaseLineFragment, TaxBaseLineFragment,
TaxDiscountFragment, TaxDiscountFragment,
} from "../../../../../generated/graphql"; } from "../../../generated/graphql";
import { ChannelConfig } from "../../../channels-configuration/channels-config"; import { ChannelConfig } from "../channels-configuration/channels-config";
import { taxLineResolver } from "../../tax-line-resolver"; import { taxLineResolver } from "../taxes/tax-line-resolver";
import { ResponseTaxPayload } from "../../types"; import { ResponseTaxPayload } from "../taxes/types";
const formatCalculatedAmount = (amount: number) => { const formatCalculatedAmount = (amount: number) => {
return Number(amount.toFixed(2)); return Number(amount.toFixed(2));

View file

@ -1,6 +1,6 @@
import TaxJar from "taxjar"; import TaxJar from "taxjar";
import { Config, TaxForOrderRes, TaxParams } from "taxjar/dist/util/types"; import { Config, TaxForOrderRes, TaxParams } from "taxjar/dist/util/types";
import { logger } from "../../../../lib/logger"; import { logger } from "../../lib/logger";
import { TaxJarConfig } from "./taxjar-config"; import { TaxJarConfig } from "./taxjar-config";
const createTaxJarSettings = (config: TaxJarConfig): Config => { const createTaxJarSettings = (config: TaxJarConfig): Config => {
@ -27,4 +27,16 @@ export class TaxJarClient {
const response: TaxForOrderRes = await this.client.taxForOrder(params); const response: TaxForOrderRes = await this.client.taxForOrder(params);
return response; return response;
} }
async ping() {
try {
await this.client.categories();
return { authenticated: true };
} catch (error) {
return {
authenticated: false,
error: "TaxJar was not able to authenticate with the provided credentials.",
};
}
}
} }

View file

@ -0,0 +1,23 @@
import { z } from "zod";
import { obfuscateSecret } from "../../lib/utils";
export const taxJarConfigSchema = z.object({
name: z.string().min(1, { message: "Name requires at least one character." }),
apiKey: z.string().min(1, { message: "API Key requires at least one character." }),
isSandbox: z.boolean(),
});
export type TaxJarConfig = z.infer<typeof taxJarConfigSchema>;
export const defaultTaxJarConfig: TaxJarConfig = {
name: "",
apiKey: "",
isSandbox: false,
};
export const taxJarInstanceConfigSchema = z.object({
id: z.string(),
provider: z.literal("taxjar"),
config: taxJarConfigSchema,
});
export type TaxJarInstanceConfig = z.infer<typeof taxJarInstanceConfigSchema>;

View file

@ -0,0 +1,116 @@
import { z } from "zod";
import { logger as pinoLogger } from "../../lib/logger";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server";
import { taxJarConfigSchema } from "./taxjar-config";
import { TaxJarConfigurationService } from "./taxjar-configuration.service";
const getInputSchema = z.object({
id: z.string(),
});
const deleteInputSchema = z.object({
id: z.string(),
});
const patchInputSchema = z.object({
id: z.string(),
value: taxJarConfigSchema.partial(),
});
const putInputSchema = z.object({
id: z.string(),
value: taxJarConfigSchema,
});
const postInputSchema = z.object({
value: taxJarConfigSchema,
});
export const taxjarConfigurationRouter = router({
get: protectedClientProcedure.input(getInputSchema).query(async ({ ctx, input }) => {
const logger = pinoLogger.child({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "taxjarConfigurationRouter.get",
});
logger.debug("taxjarConfigurationRouter.get called");
const { apiClient, saleorApiUrl } = ctx;
const taxjarConfigurationService = new TaxJarConfigurationService(apiClient, saleorApiUrl);
const result = await taxjarConfigurationService.get(input.id);
logger.debug({ result }, "taxjarConfigurationRouter.get finished");
return result;
}),
post: protectedClientProcedure.input(postInputSchema).mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "taxjarConfigurationRouter.post",
});
logger.debug("taxjarConfigurationRouter.post called");
const { apiClient, saleorApiUrl } = ctx;
const taxjarConfigurationService = new TaxJarConfigurationService(apiClient, saleorApiUrl);
const result = await taxjarConfigurationService.post(input.value);
logger.debug({ result }, "taxjarConfigurationRouter.post finished");
return result;
}),
delete: protectedClientProcedure.input(deleteInputSchema).mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "taxjarConfigurationRouter.delete",
});
logger.debug("taxjarConfigurationRouter.delete called");
const { apiClient, saleorApiUrl } = ctx;
const taxjarConfigurationService = new TaxJarConfigurationService(apiClient, saleorApiUrl);
const result = await taxjarConfigurationService.delete(input.id);
logger.debug({ result }, "taxjarConfigurationRouter.delete finished");
return result;
}),
patch: protectedClientProcedure.input(patchInputSchema).mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "taxjarConfigurationRouter.patch",
});
logger.debug("taxjarConfigurationRouter.patch called");
const { apiClient, saleorApiUrl } = ctx;
const taxjarConfigurationService = new TaxJarConfigurationService(apiClient, saleorApiUrl);
const result = await taxjarConfigurationService.patch(input.id, input.value);
logger.debug({ result }, "taxjarConfigurationRouter.patch finished");
return result;
}),
put: protectedClientProcedure.input(putInputSchema).mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "taxjarConfigurationRouter.put",
});
logger.debug("taxjarConfigurationRouter.put called");
const { apiClient, saleorApiUrl } = ctx;
const taxjarConfigurationService = new TaxJarConfigurationService(apiClient, saleorApiUrl);
const result = await taxjarConfigurationService.put(input.id, input.value);
logger.debug({ result }, "taxjarConfigurationRouter.put finished");
return result;
}),
});

View file

@ -0,0 +1,153 @@
import pino from "pino";
import { Client } from "urql";
import { createLogger } from "../../lib/logger";
import { isObfuscated, obfuscateSecret } from "../../lib/utils";
import { createSettingsManager } from "../app-configuration/metadata-manager";
import { CrudSettingsConfigurator } from "../crud-settings/crud-settings.service";
import { providersSchema } from "../providers-configuration/providers-config";
import { TAX_PROVIDER_KEY } from "../providers-configuration/providers-configuration-service";
import { TaxJarClient } from "./taxjar-client";
import {
TaxJarConfig,
taxJarConfigSchema,
TaxJarInstanceConfig,
taxJarInstanceConfigSchema,
} from "./taxjar-config";
const obfuscateConfig = (config: TaxJarConfig) => ({
...config,
apiKey: obfuscateSecret(config.apiKey),
});
const obfuscateProvidersConfig = (instances: TaxJarInstanceConfig[]) =>
instances.map((instance) => ({
...instance,
config: obfuscateConfig(instance.config),
}));
const getSchema = taxJarInstanceConfigSchema.transform((instance) => ({
...instance,
config: obfuscateConfig(instance.config),
}));
const patchSchema = taxJarConfigSchema.partial().transform((c) => {
const { apiKey, ...config } = c ?? {};
return {
...config,
...(apiKey && !isObfuscated(apiKey) && { apiKey }),
};
});
const putSchema = taxJarConfigSchema.transform((c) => {
const { apiKey, ...config } = c;
return {
...config,
...(!isObfuscated(apiKey) && { apiKey }),
};
});
export class TaxJarConfigurationService {
private crudSettingsConfigurator: CrudSettingsConfigurator;
private logger: pino.Logger;
constructor(client: Client, saleorApiUrl: string) {
const settingsManager = createSettingsManager(client);
this.crudSettingsConfigurator = new CrudSettingsConfigurator(
settingsManager,
saleorApiUrl,
TAX_PROVIDER_KEY
);
this.logger = createLogger({
service: "TaxJarConfigurationService",
metadataKey: TAX_PROVIDER_KEY,
});
}
async getAll() {
this.logger.debug(".getAll called");
const { data } = await this.crudSettingsConfigurator.readAll();
this.logger.debug({ settings: data }, `Fetched settings from crudSettingsConfigurator`);
const validation = providersSchema.safeParse(data);
if (!validation.success) {
this.logger.error({ error: validation.error.format() }, "Validation error while getAll");
throw new Error(validation.error.message);
}
const instances = validation.data.filter(
(instance) => instance.provider === "taxjar"
) as TaxJarInstanceConfig[];
return obfuscateProvidersConfig(instances);
}
async get(id: string) {
this.logger.debug(`.get called with id: ${id}`);
const { data } = await this.crudSettingsConfigurator.read(id);
this.logger.debug({ setting: data }, `Fetched setting from crudSettingsConfigurator`);
const validation = getSchema.safeParse(data);
if (!validation.success) {
this.logger.error({ error: validation.error.format() }, "Validation error while get");
throw new Error(validation.error.message);
}
return validation.data;
}
async post(config: TaxJarConfig) {
this.logger.debug(`.post called with value: ${JSON.stringify(config)}`);
const taxJarClient = new TaxJarClient(config);
const validation = await taxJarClient.ping();
if (!validation.authenticated) {
this.logger.error({ error: validation.error }, "Validation error while post");
throw new Error(validation.error);
}
return this.crudSettingsConfigurator.create({
provider: "taxjar",
config: config,
});
}
async patch(id: string, config: Partial<TaxJarConfig>) {
this.logger.debug(`.patch called with id: ${id} and value: ${JSON.stringify(config)}`);
const result = await this.get(id);
// omit the key "id" from the result
const { id: _, ...setting } = result;
const validation = patchSchema.safeParse(config);
if (!validation.success) {
this.logger.error({ error: validation.error.format() }, "Validation error while patch");
throw new Error(validation.error.message);
}
return this.crudSettingsConfigurator.update(id, {
...setting,
config: { ...setting.config, ...validation.data },
});
}
async put(id: string, config: TaxJarConfig) {
const result = await this.get(id);
// omit the key "id" from the result
const { id: _, ...setting } = result;
const validation = putSchema.safeParse(config);
if (!validation.success) {
this.logger.error({ error: validation.error.format() }, "Validation error while patch");
throw new Error(validation.error.message);
}
this.logger.debug(`.put called with id: ${id} and value: ${JSON.stringify(config)}`);
return this.crudSettingsConfigurator.update(id, {
...setting,
config: { ...validation.data },
});
}
async delete(id: string) {
this.logger.debug(`.delete called with id: ${id}`);
return this.crudSettingsConfigurator.delete(id);
}
}

View file

@ -1,10 +1,7 @@
import { TaxBaseFragment } from "../../../../../generated/graphql"; import { TaxBaseFragment } from "../../../generated/graphql";
import { logger } from "../../../../lib/logger"; import { logger } from "../../lib/logger";
import { import { ChannelConfig, defaultChannelConfig } from "../channels-configuration/channels-config";
ChannelConfig, import { TaxProvider } from "../taxes/tax-provider";
defaultChannelConfig,
} from "../../../channels-configuration/channels-config";
import { TaxProvider } from "../../tax-provider";
import { taxJarCalculate } from "./taxjar-calculate"; import { taxJarCalculate } from "./taxjar-calculate";
import { TaxJarClient } from "./taxjar-client"; import { TaxJarClient } from "./taxjar-client";
import { defaultTaxJarConfig, TaxJarConfig } from "./taxjar-config"; import { defaultTaxJarConfig, TaxJarConfig } from "./taxjar-config";

View file

@ -13,9 +13,9 @@ import { Button, makeStyles } from "@saleor/macaw-ui";
import React from "react"; import React from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { trpcClient } from "../../../../trpc/trpc-client"; import { trpcClient } from "../../trpc/trpc-client";
import { useInstanceId } from "../../../tax-context"; import { useInstanceId } from "../../taxes/tax-context";
import { taxJarInstanceConfigSchema } from "../taxjar-config"; import { taxJarConfigSchema, taxJarInstanceConfigSchema } from "../taxjar-config";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
reverseRow: { reverseRow: {
@ -25,15 +25,13 @@ const useStyles = makeStyles((theme) => ({
}, },
})); }));
const schema = taxJarInstanceConfigSchema.omit({ provider: true }); const schema = taxJarConfigSchema;
type FormValues = z.infer<typeof schema>; type FormValues = z.infer<typeof schema>;
const defaultValues: FormValues = { const defaultValues: FormValues = {
config: {
apiKey: "",
isSandbox: false,
},
name: "", name: "",
apiKey: "",
isSandbox: false,
}; };
export const TaxJarConfigurationForm = () => { export const TaxJarConfigurationForm = () => {
@ -51,16 +49,36 @@ export const TaxJarConfigurationForm = () => {
}; };
const { refetch: refetchChannelConfigurationData } = const { refetch: refetchChannelConfigurationData } =
trpcClient.channelsConfiguration.fetch.useQuery(); trpcClient.channelsConfiguration.fetch.useQuery(undefined, {
onError(error) {
appBridge?.dispatch(
actions.Notification({
title: "Error",
text: error.message,
status: "error",
})
);
},
});
const { data: providersConfigurationData, refetch: refetchProvidersConfigurationData } = const { data: providersConfigurationData, refetch: refetchProvidersConfigurationData } =
trpcClient.providersConfiguration.getAll.useQuery(); trpcClient.providersConfiguration.getAll.useQuery(undefined, {
onError(error) {
appBridge?.dispatch(
actions.Notification({
title: "Error",
text: error.message,
status: "error",
})
);
},
});
const instance = providersConfigurationData?.find((instance) => instance.id === instanceId); const instance = providersConfigurationData?.find((instance) => instance.id === instanceId);
const { mutate: createMutation, isLoading: isCreateLoading } = const { mutate: createMutation, isLoading: isCreateLoading } =
trpcClient.providersConfiguration.create.useMutation({ trpcClient.taxJarConfiguration.post.useMutation({
onSuccess({ id }) { onSuccess({ data: { id } }) {
setInstanceId(id); setInstanceId(id);
refetchProvidersConfigurationData(); refetchProvidersConfigurationData();
refetchChannelConfigurationData(); refetchChannelConfigurationData();
@ -84,7 +102,7 @@ export const TaxJarConfigurationForm = () => {
}); });
const { mutate: updateMutation, isLoading: isUpdateLoading } = const { mutate: updateMutation, isLoading: isUpdateLoading } =
trpcClient.providersConfiguration.update.useMutation({ trpcClient.taxJarConfiguration.patch.useMutation({
onSuccess() { onSuccess() {
refetchProvidersConfigurationData(); refetchProvidersConfigurationData();
refetchChannelConfigurationData(); refetchChannelConfigurationData();
@ -108,7 +126,7 @@ export const TaxJarConfigurationForm = () => {
}); });
const { mutate: deleteMutation, isLoading: isDeleteLoading } = const { mutate: deleteMutation, isLoading: isDeleteLoading } =
trpcClient.providersConfiguration.delete.useMutation({ trpcClient.taxJarConfiguration.delete.useMutation({
onSuccess() { onSuccess() {
resetInstanceId(); resetInstanceId();
refetchProvidersConfigurationData(); refetchProvidersConfigurationData();
@ -134,8 +152,8 @@ export const TaxJarConfigurationForm = () => {
React.useEffect(() => { React.useEffect(() => {
if (instance) { if (instance) {
const { provider, id, ...values } = instance; const { config } = instance;
reset(values); reset(config);
} else { } else {
reset({ ...defaultValues }); reset({ ...defaultValues });
} }
@ -145,21 +163,15 @@ export const TaxJarConfigurationForm = () => {
fullWidth: true, fullWidth: true,
}; };
const onSubmit = (values: FormValues) => { const onSubmit = (value: FormValues) => {
if (instanceId) { if (instanceId) {
updateMutation({ updateMutation({
id: instanceId, id: instanceId,
provider: { value,
...values,
provider: "taxjar",
},
}); });
} else { } else {
createMutation({ createMutation({
provider: { value,
...values,
provider: "taxjar",
},
}); });
} }
}; };
@ -200,22 +212,22 @@ export const TaxJarConfigurationForm = () => {
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<Controller <Controller
name="config.apiKey" name="apiKey"
control={control} control={control}
defaultValue={defaultValues.config.apiKey} defaultValue={defaultValues.apiKey}
render={({ field }) => <TextField label="API Key" {...field} {...textFieldProps} />} render={({ field }) => <TextField label="API Key" {...field} {...textFieldProps} />}
/> />
{formState.errors.config?.apiKey && ( {formState.errors?.apiKey && (
<FormHelperText error>{formState.errors.config?.apiKey.message}</FormHelperText> <FormHelperText error>{formState.errors?.apiKey.message}</FormHelperText>
)} )}
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<InputLabel> <InputLabel>
Sandbox Sandbox
<Controller <Controller
name={"config.isSandbox"} name={"isSandbox"}
control={control} control={control}
defaultValue={defaultValues.config.isSandbox} defaultValue={defaultValues.isSandbox}
render={({ field }) => ( render={({ field }) => (
<Switch <Switch
{...field} {...field}

View file

@ -2,11 +2,15 @@ import { channelsRouter } from "../channels/channels.router";
import { router } from "./trpc-server"; import { router } from "./trpc-server";
import { providersConfigurationRouter } from "../providers-configuration/providers-configuration.router"; import { providersConfigurationRouter } from "../providers-configuration/providers-configuration.router";
import { channelsConfigurationRouter } from "../channels-configuration/channels-configuration.router"; import { channelsConfigurationRouter } from "../channels-configuration/channels-configuration.router";
import { taxjarConfigurationRouter } from "../taxjar/taxjar-configuration.router";
import { avataxConfigurationRouter } from "../avatax/avatax-configuration.router";
export const appRouter = router({ export const appRouter = router({
channels: channelsRouter, channels: channelsRouter,
providersConfiguration: providersConfigurationRouter, providersConfiguration: providersConfigurationRouter,
channelsConfiguration: channelsConfigurationRouter, channelsConfiguration: channelsConfigurationRouter,
taxJarConfiguration: taxjarConfigurationRouter,
avataxConfiguration: avataxConfigurationRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View file

@ -5,7 +5,7 @@ import { createClient } from "../../../lib/graphql";
import { createLogger } from "../../../lib/logger"; import { createLogger } from "../../../lib/logger";
import { calculateTaxesPayloadSchema, ExpectedWebhookPayload } from "../../../lib/saleor/schema"; import { calculateTaxesPayloadSchema, ExpectedWebhookPayload } from "../../../lib/saleor/schema";
import { GetChannelsConfigurationService } from "../../../modules/channels-configuration/get-channels-configuration.service"; import { GetChannelsConfigurationService } from "../../../modules/channels-configuration/get-channels-configuration.service";
import { GetProvidersConfigurationService } from "../../../modules/providers-configuration/get-providers-configuration.service"; import { TaxProvidersConfigurationService } from "../../../modules/providers-configuration/providers-configuration-service";
import { ActiveTaxProvider } from "../../../modules/taxes/active-tax-provider"; import { ActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
export const config = { export const config = {
@ -30,7 +30,7 @@ export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res,
const validation = calculateTaxesPayloadSchema.safeParse(payload); const validation = calculateTaxesPayloadSchema.safeParse(payload);
if (!validation.success) { if (!validation.success) {
logger.error({ error: validation.error.message }, "Payload is invalid"); logger.error({ error: validation.error.format() }, "Payload is invalid");
logger.info("Returning no data"); logger.info("Returning no data");
return res.send({}); return res.send({});
} }
@ -43,10 +43,10 @@ export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res,
Promise.resolve({ token: authData.token }) Promise.resolve({ token: authData.token })
); );
const providersConfig = await new GetProvidersConfigurationService({ const providersConfig = await new TaxProvidersConfigurationService(
saleorApiUrl: authData.saleorApiUrl, client,
apiClient: client, authData.saleorApiUrl
}).getConfiguration(); ).getAll();
const channelsConfig = await new GetChannelsConfigurationService({ const channelsConfig = await new GetChannelsConfigurationService({
saleorApiUrl: authData.saleorApiUrl, saleorApiUrl: authData.saleorApiUrl,

View file

@ -5,7 +5,7 @@ import { createClient } from "../../../lib/graphql";
import { createLogger } from "../../../lib/logger"; import { createLogger } from "../../../lib/logger";
import { calculateTaxesPayloadSchema, ExpectedWebhookPayload } from "../../../lib/saleor/schema"; import { calculateTaxesPayloadSchema, ExpectedWebhookPayload } from "../../../lib/saleor/schema";
import { GetChannelsConfigurationService } from "../../../modules/channels-configuration/get-channels-configuration.service"; import { GetChannelsConfigurationService } from "../../../modules/channels-configuration/get-channels-configuration.service";
import { GetProvidersConfigurationService } from "../../../modules/providers-configuration/get-providers-configuration.service"; import { TaxProvidersConfigurationService } from "../../../modules/providers-configuration/providers-configuration-service";
import { ActiveTaxProvider } from "../../../modules/taxes/active-tax-provider"; import { ActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
export const config = { export const config = {
@ -30,7 +30,7 @@ export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx
const validation = calculateTaxesPayloadSchema.safeParse(payload); const validation = calculateTaxesPayloadSchema.safeParse(payload);
if (!validation.success) { if (!validation.success) {
logger.error({ error: validation.error.message }, "Payload is invalid"); logger.error({ error: validation.error.format() }, "Payload is invalid");
logger.info("Returning no data"); logger.info("Returning no data");
return res.status(200).json({}); return res.status(200).json({});
} }
@ -42,11 +42,10 @@ export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx
const client = createClient(authData.saleorApiUrl, async () => const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token }) Promise.resolve({ token: authData.token })
); );
const providersConfig = await new TaxProvidersConfigurationService(
const providersConfig = await new GetProvidersConfigurationService({ client,
saleorApiUrl: authData.saleorApiUrl, authData.saleorApiUrl
apiClient: client, ).getAll();
}).getConfiguration();
const channelsConfig = await new GetChannelsConfigurationService({ const channelsConfig = await new GetChannelsConfigurationService({
saleorApiUrl: authData.saleorApiUrl, saleorApiUrl: authData.saleorApiUrl,