feat: redesign taxes app with macaw-ui (#433)

* refactor: ♻️ replace material-ui with macaw-ui; remove all views

* add apps-ui

* feat: add configuration screen (#514)

* feat:  add basic layout

* refactor: 🔥 enabled field

* refactor: ♻️ move part of config to credentials

* refactor: ♻️ move address from channel to provider

* feat:  add all states for providers

* refactor: ♻️ merge redesign with refactor

* refactor: ♻️ replace material-ui with macaw-ui; remove all views

* fix: 🐛 text-decoration on breadcrumb link

* feat:  add stripe icon

* feat:  add AppCard

* feat:  add AppBreadcrumbs and new provider pages

* refactor: ♻️ add gridRatio to support two app grids

* refactor: 🔥 theme-synchronizer test

* refactor: ♻️ make breadcrumbs bulletproof

* fix: 🐛 failing build

* feat:  add app-layout

* build: ⬆️ upgrade macaw-ui

* feat: redesign provider forms (#533)

* build:  add @saleor/react-hook-form-macaw

* feat: add binding for Toggle in react-hook-form-macaw

* feat:  add AppToggle component

* feat:  add create-avatax-configuration and edit-avatax-configuration

* feat:  add taxjar-validation-service with address validation

* feat:  add AvataxValidationService

* feat:  add edit/create taxjar config

* feat:  add delete configuration

* refactor: ♻️ change invalid address error message

* refactor: ♻️ throw in validation services instead of authenticated: true/false

* docs: 💡 add todo comments

* refactor: ♻️ better variable naming in parse error

* fix: 🐛 disable button when loading

* refactor: ♻️ make defaultValues required

* refactor: 🔥 remove custom fontWeights

* fix: 🐛 toggle form submission bug

* feat:  extract provider-label and use in forms

* feat:  use new Section component and change copy

* refactor: ♻️ use ProviderLabel in ProvidersTable

* build: 💚 fix lockfile

* fix: 🐛 address feedback

* feat: channel provider table (#538)

* refactor: 🔥 providers-config-input-schema

* refactor: ♻️ move providers

* feat:  add basic channel-list

* refactor: 🚚 move around

* feat:  add ChannelConfigurationService

* feat:  add select provider in channel table

* refactor: 🔥 logger traces

* refactor: 🔊 refine logs

* feat:  add avatax-config-mock-generator and use in webhooks mock generators

* refactor: ♻️ split up avatax-validation.service and add tests for response resolver

* feat:  add avatax-validation-error-resolver tests

* refactor: 🚚 move taxjar-validation.service to separate folder

* feat:  extract taxjar-validation-error-resolver and add tests

* feat:  add channel-config-mock-generator

* feat:  add ChannelFetcherMockGenerator

* test:  add tests for channel-configuration-merge

* test:  add tests for crud-settings.service

* fix: 💬 channel copy

* refactor: 🔊 remove data from logs

* refactor: 🚚 move configuration & validation to configuration folder

* fix: 🐛 imports

* fix: 🐛 showing loading & add at the same time

* refactor: ♻️ public configurations & obfuscation rework

* refactor: 🚚 extract repositories from services

* refactor: 🚚 instance -> connection (#544)

* refactor: ♻️ move creating configuration service to procedure

* fix: 🐛 some paths & names while renaming

* Update apps/taxes/src/pages/api/webhooks/order-calculate-taxes.ts

* Update apps/taxes/src/pages/api/webhooks/checkout-calculate-taxes.ts

* feat: shipping tax code (#555)

* fix: 💄 minor visual issues

* feat:  add shipping tax code

* fix: 🐛 fix toggle

* refactor: ♻️ use common table in providers-table

* docs: 💡 add comment to obfuscator

* feat:  sort countries by label not code

* Taxes 💸: Add runtime migrations to schema v2 (#545)

* build:  add dotenv

* feat: 🚧 add draft of migration scripts

* refactor: ♻️ settingsManager to accept appId

* feat:  tax-providers and channels transforms

* feat:  add migration classes

* feat: 🚧 add migration to connection getAll

* feat: 🚧 add dummy data script

* feat:  add runtime channel migration

* feat:  add migration and report scripts

* fix: 🐛 name of dummy class method

* feat:  add transform tests

* build: 💚 update lockfile

* fix: creating & updating a provider (#563)

* refactor:  improve the test for update

* docs: 💡 comment crud-settings

* refactor: 🔥 unused file

* fix: 🐛 appId bug

* refactor: 🚚 rename dummy-utils

* fix: 💄 padding in table

* refactor: 💬 change avatax texts

* build: 👷 add changeset
This commit is contained in:
Adrian Pilarczyk 2023-06-15 09:01:50 +02:00 committed by GitHub
parent cb6ee2920b
commit f96563fc2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
197 changed files with 5348 additions and 3635 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-taxes": major
---
Redesigned the app's UI with the new version of macaw-ui. Introduced breaking changes in the structure of providers configuration and channels configuration. Added migrations that convert the obsolete configurations to the new format. Added address validation for tax providers.

View file

@ -0,0 +1,5 @@
---
"@saleor/react-hook-form-macaw": minor
---
Added a binding for the macaw-ui's Toggle component.

View file

@ -0,0 +1,5 @@
---
"@saleor/apps-ui": patch
---
Fixed a missing text-decoration on a breadcrumb link.

View file

@ -1,31 +1,52 @@
/*
* This file sets a custom webpack configuration to use your Next.js app
* with Sentry.
* https://nextjs.org/docs/api-reference/next.config.js/introduction
* https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
*/
const { withSentryConfig } = require("@sentry/nextjs");
const isSentryPropertiesInEnvironment =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG;
/**
* @type {import('next').NextConfig}
*/
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
experimental: {
esmExternals: true,
},
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui"],
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"],
};
const isSentryEnvAvailable =
process.env.SENTRY_AUTH_TOKEN &&
process.env.SENTRY_PROJECT &&
process.env.SENTRY_ORG &&
process.env.SENTRY_AUTH_TOKEN;
const { withSentryConfig } = require("@sentry/nextjs");
module.exports = withSentryConfig(
config,
{ silent: true },
{
hideSourcemaps: true,
disableServerWebpackPlugin: !isSentryPropertiesInEnvironment,
disableClientWebpackPlugin: !isSentryPropertiesInEnvironment,
/*
* For all available options, see:
* https://github.com/getsentry/sentry-webpack-plugin#options
*/
// Suppresses source map uploading logs during build
silent: true,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
},
{
disableClientWebpackPlugin: !isSentryEnvAvailable,
disableServerWebpackPlugin: !isSentryEnvAvailable,
/*
* For all available options, see:
* https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
*/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Transpiles SDK to be compatible with IE11 (increases bundle size)
transpileClientSDK: true,
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
tunnelRoute: "/monitoring",
// Hides source maps from generated client bundles
hideSourceMaps: true,
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
}
);

View file

@ -13,13 +13,11 @@
},
"dependencies": {
"@hookform/resolvers": "^2.9.10",
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
"@material-ui/lab": "4.0.0-alpha.61",
"@saleor/app-sdk": "0.40.1",
"@saleor/apps-shared": "workspace:*",
"@saleor/apps-ui": "workspace:*",
"@saleor/macaw-ui": "^0.7.2",
"@saleor/macaw-ui": "^0.8.0-pre.84",
"@saleor/react-hook-form-macaw": "workspace:*",
"@sentry/nextjs": "^7.45.0",
"@tanstack/react-query": "^4.19.1",
"@trpc/client": "^10.9.0",
@ -30,7 +28,8 @@
"@urql/exchange-multipart-fetch": "^1.0.1",
"avatax": "^23.3.2",
"clsx": "^1.2.1",
"graphql": "16.6.0",
"dotenv": "^16.0.3",
"graphql": "^16.6.0",
"graphql-tag": "^2.12.6",
"jotai": "^2.0.0",
"jsdom": "^20.0.3",
@ -39,7 +38,7 @@
"pino-pretty": "^10.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.42.1",
"react-hook-form": "^7.43.9",
"taxjar": "^4.0.1",
"urql": "^3.0.3",
"usehooks-ts": "^2.9.1",

View file

@ -0,0 +1,21 @@
import { z } from "zod";
const addressSchema = z.object({
country: z.string(),
zip: z.string(),
state: z.string(),
city: z.string(),
street: z.string(),
});
const channelSchema = z.object({
providerInstanceId: z.string(),
enabled: z.boolean(),
address: addressSchema,
});
export type ChannelV1 = z.infer<typeof channelSchema>;
const channelsV1Schema = z.record(channelSchema);
export type ChannelsV1 = z.infer<typeof channelsV1Schema>;

View file

@ -0,0 +1,6 @@
import { z } from "zod";
import { channelsSchema } from "../../src/modules/channel-configuration/channel-config";
export const channelsV2Schema = channelsSchema;
export type ChannelsV2 = z.infer<typeof channelsV2Schema>;

View file

@ -0,0 +1,30 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { SaleorCloudAPL } from "@saleor/app-sdk/APL";
import { createClient } from "../../src/lib/graphql";
import { createSettingsManager } from "../../src/modules/app/metadata-manager";
export const getMetadataManagerForEnv = (apiUrl: string, appToken: string, appId: string) => {
const client = createClient(apiUrl, async () => ({
token: appToken,
}));
return createSettingsManager(client, appId);
};
export const verifyRequiredEnvs = () => {
const requiredEnvs = ["SALEOR_CLOUD_TOKEN", "SALEOR_CLOUD_RESOURCE_URL", "SECRET_KEY"];
if (!requiredEnvs.every((env) => process.env[env])) {
throw new Error(`Missing envs: ${requiredEnvs.join(" | ")}`);
}
};
export const fetchCloudAplEnvs = () => {
const saleorAPL = new SaleorCloudAPL({
token: process.env.SALEOR_CLOUD_TOKEN!,
resourceUrl: process.env.SALEOR_CLOUD_RESOURCE_URL!,
});
return saleorAPL.getAll();
};

View file

@ -0,0 +1,122 @@
import { saleorApp } from "../../saleor-app";
import { createClient } from "../../src/lib/graphql";
import { Logger, createLogger } from "../../src/lib/logger";
import { createSettingsManager } from "../../src/modules/app/metadata-manager";
import { TaxProvidersV1 } from "./tax-providers-config-schema-v1";
import { TaxProvidersPrivateMetadataManagerV1 } from "./tax-providers-metadata-manager-v1";
import { ChannelsV1 } from "./channels-config-schema-v1";
import * as dotenv from "dotenv";
import { TaxChannelsPrivateMetadataManagerV1 } from "./tax-channels-metadata-manager-v1";
dotenv.config();
export const dummyChannelsV1Config: ChannelsV1 = {
"default-channel": {
providerInstanceId: "24822834-1a49-4b51-8a59-579affdb772f",
address: {
city: "San Francisco",
country: "US",
state: "CA",
street: "Sesame Street",
zip: "10001",
},
enabled: true,
},
"channel-pln": {
providerInstanceId: "d15d9907-a3cb-42d2-9336-366d2366e91b",
address: {
city: "San Francisco",
country: "US",
state: "CA",
street: "Sesame Street",
zip: "10001",
},
enabled: true,
},
};
export const dummyTaxProvidersV1Config: TaxProvidersV1 = [
{
provider: "avatax",
id: "24822834-1a49-4b51-8a59-579affdb772f",
config: {
isAutocommit: true,
isSandbox: true,
name: "Avatalara1",
password: "password",
username: "username",
companyCode: "companyCode",
shippingTaxCode: "shippingTaxCode",
},
},
{
provider: "taxjar",
id: "d15d9907-a3cb-42d2-9336-366d2366e91b",
config: {
isSandbox: true,
apiKey: "apiKey",
name: "TaxJar1",
},
},
];
// This class is used to generate dummy config for the app to check if the runtime migrations work as expected.
class DummyConfigGenerator {
private logger: Logger;
constructor(private domain: string) {
this.logger = createLogger({
name: "DummyConfigGenerator",
});
}
private getFileApl = () => {
return saleorApp.apl.getAll();
};
private generateDummyTaxProvidersConfig = (): TaxProvidersV1 => dummyTaxProvidersV1Config;
private generateDummyTaxChannelsConfig = (): ChannelsV1 => dummyChannelsV1Config;
generate = async () => {
console.log("Generating dummy config");
const apls = await this.getFileApl();
console.log({ apls }, "Apls retrieved");
const target = apls.find((apl) => apl.domain === this.domain);
if (!target) {
throw new Error(`Domain ${this.domain} not found in apls`);
}
const dummyTaxProvidersConfig = this.generateDummyTaxProvidersConfig();
const dummyTaxChannelsConfig = this.generateDummyTaxChannelsConfig();
console.log({ dummyTaxProvidersConfig, dummyTaxChannelsConfig }, "Dummy configs generated");
const client = createClient(target.saleorApiUrl, async () =>
Promise.resolve({ token: target.token })
);
const metadataManager = createSettingsManager(client, target.appId);
const taxProvidersManager = new TaxProvidersPrivateMetadataManagerV1(
metadataManager,
target.saleorApiUrl
);
const taxChannelsManager = new TaxChannelsPrivateMetadataManagerV1(
metadataManager,
target.saleorApiUrl
);
console.log("Setting dummy configs");
await taxProvidersManager.setConfig(dummyTaxProvidersConfig);
await taxChannelsManager.setConfig(dummyTaxChannelsConfig);
console.log("Dummy config set");
};
}
// const dummyConfigGenerator = new DummyConfigGenerator("");
// dummyConfigGenerator.generate();

View file

@ -0,0 +1,78 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import * as dotenv from "dotenv";
import { fetchCloudAplEnvs, getMetadataManagerForEnv, verifyRequiredEnvs } from "./migration-utils";
import { TaxProvidersV1toV2MigrationManager } from "./tax-providers-migration-v1-to-v2";
import { TaxChannelsV1toV2MigrationManager } from "./tax-channels-migration-v1-to-v2";
dotenv.config();
const runMigration = async () => {
console.log("Starting running migration");
verifyRequiredEnvs();
console.log("Envs verified, fetching envs");
const allEnvs = await fetchCloudAplEnvs().catch((r) => {
console.error(r);
process.exit(1);
});
const report = {
taxProviders: [] as string[],
taxChannels: [] as string[],
none: [] as string[],
};
for (const env of allEnvs) {
let isTaxProvidersMigrated = false;
let isTaxChannelsMigrated = false;
console.log("Working on env: ", env.saleorApiUrl);
const metadataManager = getMetadataManagerForEnv(env.saleorApiUrl, env.token, env.appId);
const taxProvidersMigrationManager = new TaxProvidersV1toV2MigrationManager(
metadataManager,
env.saleorApiUrl,
{ mode: "migrate" }
);
const taxProvidersMigratedConfig = await taxProvidersMigrationManager.migrateIfNeeded();
if (taxProvidersMigratedConfig) {
console.log("Config migrated", taxProvidersMigratedConfig);
isTaxProvidersMigrated = true;
}
const taxChannelsMigrationManager = new TaxChannelsV1toV2MigrationManager(
metadataManager,
env.saleorApiUrl,
{ mode: "migrate" }
);
const taxChannelsMigratedConfig = await taxChannelsMigrationManager.migrateIfNeeded();
if (taxChannelsMigratedConfig) {
console.log("Config migrated", taxChannelsMigratedConfig);
isTaxChannelsMigrated = true;
}
if (isTaxProvidersMigrated) {
report.taxProviders.push(env.saleorApiUrl);
}
if (isTaxChannelsMigrated) {
report.taxChannels.push(env.saleorApiUrl);
}
if (!isTaxProvidersMigrated && !isTaxChannelsMigrated) {
report.none.push(env.saleorApiUrl);
}
}
console.log("Report", report);
};
runMigration();

View file

@ -0,0 +1,78 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import * as dotenv from "dotenv";
import { fetchCloudAplEnvs, getMetadataManagerForEnv, verifyRequiredEnvs } from "./migration-utils";
import { TaxProvidersV1toV2MigrationManager } from "./tax-providers-migration-v1-to-v2";
import { TaxChannelsV1toV2MigrationManager } from "./tax-channels-migration-v1-to-v2";
dotenv.config();
const runReport = async () => {
console.log("Starting running migration");
verifyRequiredEnvs();
console.log("Envs verified, fetching envs");
const allEnvs = await fetchCloudAplEnvs().catch((r) => {
console.error(r);
process.exit(1);
});
const report = {
taxProviders: [] as string[],
taxChannels: [] as string[],
none: [] as string[],
};
for (const env of allEnvs) {
let isTaxProvidersMigrated = false;
let isTaxChannelsMigrated = false;
console.log("Working on env: ", env.saleorApiUrl);
const metadataManager = getMetadataManagerForEnv(env.saleorApiUrl, env.token, env.appId);
const taxProvidersMigrationManager = new TaxProvidersV1toV2MigrationManager(
metadataManager,
env.saleorApiUrl,
{ mode: "report" }
);
const taxProvidersMigratedConfig = await taxProvidersMigrationManager.migrateIfNeeded();
if (taxProvidersMigratedConfig) {
console.log("Config migrated", taxProvidersMigratedConfig);
isTaxProvidersMigrated = true;
}
const taxChannelsMigrationManager = new TaxChannelsV1toV2MigrationManager(
metadataManager,
env.saleorApiUrl,
{ mode: "report" }
);
const taxChannelsMigratedConfig = await taxChannelsMigrationManager.migrateIfNeeded();
if (taxChannelsMigratedConfig) {
console.log("Config migrated", taxChannelsMigratedConfig);
isTaxChannelsMigrated = true;
}
if (isTaxProvidersMigrated) {
report.taxProviders.push(env.saleorApiUrl);
}
if (isTaxChannelsMigrated) {
report.taxChannels.push(env.saleorApiUrl);
}
if (!isTaxProvidersMigrated && !isTaxChannelsMigrated) {
report.none.push(env.saleorApiUrl);
}
}
console.log("Report", report);
};
runReport();

View file

@ -0,0 +1,32 @@
// TODO: MIGRATION CODE FROM CONFIG VERSION V1. REMOVE THIS FILE AFTER MIGRATION
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { ChannelsV1 } from "./channels-config-schema-v1";
export class TaxChannelsPrivateMetadataManagerV1 {
private metadataKey = "tax-channels";
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
getConfig(): Promise<ChannelsV1 | undefined> {
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
if (!data) {
return data;
}
try {
return JSON.parse(data);
} catch (e) {
throw new Error("Invalid metadata value, cant be parsed");
}
});
}
setConfig(config: ChannelsV1): Promise<void> {
return this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(config),
domain: this.saleorApiUrl,
});
}
}

View file

@ -0,0 +1,32 @@
// TODO: MIGRATION CODE FROM CONFIG VERSION V1. REMOVE THIS FILE AFTER MIGRATION
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { ChannelsV2 } from "./channels-config-schema-v2";
export class TaxChannelsPrivateMetadataManagerV2 {
private metadataKey = "tax-channels-v2";
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
getConfig(): Promise<ChannelsV2 | undefined> {
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
if (!data) {
return data;
}
try {
return JSON.parse(data);
} catch (e) {
throw new Error("Invalid metadata value, cant be parsed");
}
});
}
setConfig(config: ChannelsV2): Promise<void> {
return this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(config),
domain: this.saleorApiUrl,
});
}
}

View file

@ -0,0 +1,55 @@
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { Logger, createLogger } from "../../src/lib/logger";
import { TaxChannelsPrivateMetadataManagerV1 } from "./tax-channels-metadata-manager-v1";
import { TaxChannelsPrivateMetadataManagerV2 } from "./tax-channels-metadata-manager-v2";
import { TaxChannelsTransformV1toV2 } from "./tax-channels-transform-v1-to-v2";
export class TaxChannelsV1toV2MigrationManager {
private logger: Logger;
constructor(
private metadataManager: SettingsManager,
private saleorApiUrl: string,
private options: { mode: "report" | "migrate" } = { mode: "migrate" }
) {
this.logger = createLogger({
location: "TaxChannelsV1toV2MigrationManager",
});
}
async migrateIfNeeded() {
const taxChannelsManagerV1 = new TaxChannelsPrivateMetadataManagerV1(
this.metadataManager,
this.saleorApiUrl
);
const taxChannelsManagerV2 = new TaxChannelsPrivateMetadataManagerV2(
this.metadataManager,
this.saleorApiUrl
);
const currentConfig = await taxChannelsManagerV2.getConfig();
if (currentConfig) {
this.logger.info("Migration is not necessary, we have current config.");
return currentConfig;
}
const previousChannelConfig = await taxChannelsManagerV1.getConfig();
if (!previousChannelConfig) {
this.logger.info("Previous config not found. Migration not possible.");
return;
}
this.logger.info("Previous config found. Migrating...");
const transformer = new TaxChannelsTransformV1toV2();
const nextConfig = transformer.transform(previousChannelConfig);
if (this.options.mode === "migrate") {
await taxChannelsManagerV2.setConfig(nextConfig);
}
return nextConfig;
}
}

View file

@ -0,0 +1,34 @@
import { dummyChannelsV1Config } from "./run-generate-dummy-data";
import { TaxChannelsTransformV1toV2 } from "./tax-channels-transform-v1-to-v2";
import { describe, expect, it } from "vitest";
const transformer = new TaxChannelsTransformV1toV2();
describe("TaxChannelsTransformV1toV2", () => {
it("should transform v1 to v2", () => {
const result = transformer.transform(dummyChannelsV1Config);
expect(result).toEqual([
{
id: expect.any(String),
config: {
providerConnectionId: "24822834-1a49-4b51-8a59-579affdb772f",
slug: "default-channel",
},
},
{
id: expect.any(String),
config: {
providerConnectionId: "d15d9907-a3cb-42d2-9336-366d2366e91b",
slug: "channel-pln",
},
},
]);
});
it("should return empty array if no channels are provided", () => {
const result = transformer.transform({});
expect(result).toEqual([]);
});
});

View file

@ -0,0 +1,20 @@
import { createId } from "../../src/lib/utils";
import { ChannelsV1 } from "./channels-config-schema-v1";
import { ChannelsV2 } from "./channels-config-schema-v2";
export class TaxChannelsTransformV1toV2 {
transform(channels: ChannelsV1): ChannelsV2 {
return Object.keys(channels).map((slug) => {
const channel = channels[slug];
return {
// * There was no id in v1, so we need to generate it
id: createId(),
config: {
providerConnectionId: channel.providerInstanceId,
slug,
},
};
});
}
}

View file

@ -0,0 +1,40 @@
import { z } from "zod";
const avataxConfigSchema = z.object({
name: z.string().min(1, { message: "Name requires at least one character." }),
username: z.string().min(1, { message: "Username requires at least one character." }),
password: z.string().min(1, { message: "Password requires at least one character." }),
isSandbox: z.boolean(),
companyCode: z.string().optional(),
isAutocommit: z.boolean(),
shippingTaxCode: z.string().optional(),
});
const avataxInstanceConfigV1Schema = z.object({
id: z.string(),
provider: z.literal("avatax"),
config: avataxConfigSchema,
});
export type AvataxInstanceConfigV1 = z.infer<typeof avataxInstanceConfigV1Schema>;
const taxJarConfigSchema = z.object({
name: z.string().min(1, { message: "Name requires at least one character." }),
apiKey: z.string().min(1, { message: "API Key requires at least one character." }),
isSandbox: z.boolean(),
});
const taxJarInstanceConfigV1Schema = z.object({
id: z.string(),
provider: z.literal("taxjar"),
config: taxJarConfigSchema,
});
export type TaxJarInstanceConfigV1 = z.infer<typeof taxJarInstanceConfigV1Schema>;
const taxProviderV1Schema = taxJarInstanceConfigV1Schema.or(avataxInstanceConfigV1Schema);
export type TaxProviderV1 = z.infer<typeof taxProviderV1Schema>;
const taxProvidersV1Schema = z.array(taxProviderV1Schema);
export type TaxProvidersV1 = z.infer<typeof taxProvidersV1Schema>;

View file

@ -0,0 +1,6 @@
import { z } from "zod";
import { providerConnectionsSchema } from "../../src/modules/provider-connections/provider-connections";
const taxProvidersV2Schema = providerConnectionsSchema;
export type TaxProvidersV2 = z.infer<typeof taxProvidersV2Schema>;

View file

@ -0,0 +1,32 @@
// TODO: MIGRATION CODE FROM CONFIG VERSION V1. REMOVE THIS FILE AFTER MIGRATION
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { TaxProvidersV1 } from "./tax-providers-config-schema-v1";
export class TaxProvidersPrivateMetadataManagerV1 {
private metadataKey = "tax-providers";
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
getConfig(): Promise<TaxProvidersV1 | undefined> {
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
if (!data) {
return data;
}
try {
return JSON.parse(data);
} catch (e) {
throw new Error("Invalid metadata value, cant be parsed");
}
});
}
setConfig(config: TaxProvidersV1): Promise<void> {
return this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(config),
domain: this.saleorApiUrl,
});
}
}

View file

@ -0,0 +1,33 @@
// TODO: MIGRATION CODE FROM CONFIG VERSION V1. REMOVE THIS FILE AFTER MIGRATION
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { TaxProvidersV2 } from "./tax-providers-config-schema-v2";
import { TAX_PROVIDER_KEY } from "../../src/modules/provider-connections/public-provider-connections.service";
export class TaxProvidersPrivateMetadataManagerV2 {
private metadataKey = TAX_PROVIDER_KEY;
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
getConfig(): Promise<TaxProvidersV2 | undefined> {
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
if (!data) {
return data;
}
try {
return JSON.parse(data);
} catch (e) {
throw new Error("Invalid metadata value, cant be parsed");
}
});
}
setConfig(config: TaxProvidersV2): Promise<void> {
return this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(config),
domain: this.saleorApiUrl,
});
}
}

View file

@ -0,0 +1,66 @@
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { Logger, createLogger } from "../../src/lib/logger";
import { TaxProvidersPrivateMetadataManagerV1 } from "./tax-providers-metadata-manager-v1";
import { TaxProvidersPrivateMetadataManagerV2 } from "./tax-providers-metadata-manager-v2";
import { TaxProvidersV1ToV2Transformer } from "./tax-providers-transform-v1-to-v2";
import { TaxChannelsPrivateMetadataManagerV1 } from "./tax-channels-metadata-manager-v1";
export class TaxProvidersV1toV2MigrationManager {
private logger: Logger;
constructor(
private metadataManager: SettingsManager,
private saleorApiUrl: string,
private options: { mode: "report" | "migrate" } = { mode: "migrate" }
) {
this.logger = createLogger({
location: "TaxProvidersV1toV2MigrationManager",
});
}
async migrateIfNeeded() {
const taxProvidersManagerV1 = new TaxProvidersPrivateMetadataManagerV1(
this.metadataManager,
this.saleorApiUrl
);
const taxProvidersManagerV2 = new TaxProvidersPrivateMetadataManagerV2(
this.metadataManager,
this.saleorApiUrl
);
const taxChannelsManagerV1 = new TaxChannelsPrivateMetadataManagerV1(
this.metadataManager,
this.saleorApiUrl
);
const currentTaxProvidersConfig = await taxProvidersManagerV2.getConfig();
if (currentTaxProvidersConfig) {
this.logger.info("Migration is not necessary, the config is up to date.");
return;
}
this.logger.info("Current config not found.");
const previousTaxProvidersConfig = await taxProvidersManagerV1.getConfig();
const previousChannelConfig = await taxChannelsManagerV1.getConfig();
if (!previousTaxProvidersConfig || !previousChannelConfig) {
this.logger.info(
{ previousChannelConfig, previousTaxProvidersConfig },
"Previous config not found. Migration not possible."
);
return;
}
this.logger.info("Previous config found. Migrating...");
const transformer = new TaxProvidersV1ToV2Transformer();
const nextConfig = transformer.transform(previousTaxProvidersConfig, previousChannelConfig);
if (this.options.mode === "migrate") {
await taxProvidersManagerV2.setConfig(nextConfig);
}
return nextConfig;
}
}

View file

@ -0,0 +1,60 @@
import { dummyChannelsV1Config, dummyTaxProvidersV1Config } from "./run-generate-dummy-data";
import { TaxProvidersV1ToV2Transformer } from "./tax-providers-transform-v1-to-v2";
import { describe, expect, it } from "vitest";
const transformer = new TaxProvidersV1ToV2Transformer();
describe("TaxProvidersV1ToV2Transformer", () => {
it("should transform v1 to v2", () => {
const result = transformer.transform(dummyTaxProvidersV1Config, dummyChannelsV1Config);
expect(result).toEqual([
{
id: "24822834-1a49-4b51-8a59-579affdb772f",
provider: "avatax",
config: {
name: "Avatalara1",
isSandbox: true,
isAutocommit: true,
credentials: {
username: "username",
password: "password",
},
companyCode: "companyCode",
shippingTaxCode: "shippingTaxCode",
address: {
city: "San Francisco",
country: "US",
state: "CA",
street: "Sesame Street",
zip: "10001",
},
},
},
{
id: "d15d9907-a3cb-42d2-9336-366d2366e91b",
provider: "taxjar",
config: {
name: "TaxJar1",
isSandbox: true,
credentials: {
apiKey: "apiKey",
},
address: {
city: "San Francisco",
country: "US",
state: "CA",
street: "Sesame Street",
zip: "10001",
},
},
},
]);
});
it("should return empty array if no channels and providers are provided", () => {
const result = transformer.transform([], {});
expect(result).toEqual([]);
});
});

View file

@ -0,0 +1,91 @@
import { AvataxConnection } from "../../src/modules/avatax/avatax-connection-schema";
import { TaxJarConnection } from "../../src/modules/taxjar/taxjar-connection-schema";
import { ChannelV1, ChannelsV1 } from "./channels-config-schema-v1";
import {
AvataxInstanceConfigV1,
TaxJarInstanceConfigV1,
TaxProvidersV1,
} from "./tax-providers-config-schema-v1";
import { TaxProvidersV2 } from "./tax-providers-config-schema-v2";
export class TaxProvidersV1ToV2Transformer {
private findTaxProviderChannelConfig = (id: string, channelsConfig: ChannelsV1): ChannelV1 => {
const channel = Object.values(channelsConfig).find(
(channel) => channel.providerInstanceId === id
);
if (!channel) {
throw new Error(`Channel with id ${id} not found`);
}
return channel;
};
private transformAvataxInstance = (
instance: AvataxInstanceConfigV1,
channel: ChannelV1
): AvataxConnection => {
return {
id: instance.id,
provider: "avatax",
config: {
name: instance.config.name,
address: {
city: channel.address.city,
country: channel.address.country,
state: channel.address.state,
street: channel.address.street,
zip: channel.address.zip,
},
credentials: {
password: instance.config.password,
username: instance.config.username,
},
isAutocommit: instance.config.isAutocommit,
isSandbox: instance.config.isSandbox,
companyCode: instance.config.companyCode,
shippingTaxCode: instance.config.shippingTaxCode,
},
};
};
private transformTaxJarInstance = (
instance: TaxJarInstanceConfigV1,
channel: ChannelV1
): TaxJarConnection => {
return {
id: instance.id,
provider: "taxjar",
config: {
name: instance.config.name,
address: {
city: channel.address.city,
country: channel.address.country,
state: channel.address.state,
street: channel.address.street,
zip: channel.address.zip,
},
credentials: {
apiKey: instance.config.apiKey,
},
isSandbox: instance.config.isSandbox,
},
};
};
transform = (taxProvidersConfig: TaxProvidersV1, channelsConfig: ChannelsV1): TaxProvidersV2 => {
return taxProvidersConfig.map((instance) => {
const channel = this.findTaxProviderChannelConfig(instance.id, channelsConfig);
if (instance.provider === "avatax") {
return this.transformAvataxInstance(instance, channel);
}
if (instance.provider === "taxjar") {
return this.transformTaxJarInstance(instance, channel);
}
throw new Error(`Unknown provider `);
});
};
}

View file

@ -1,17 +0,0 @@
// This file configures the initialization of Sentry on the browser.
// The config you add here will be used whenever a page is visited.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});

View file

@ -0,0 +1,34 @@
/*
* This file configures the initialization of Sentry on the client.
* The config you add here will be used whenever a users loads a page in their browser.
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
*/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
replaysOnErrorSampleRate: 1.0,
/*
* This sets the sample rate to be 10%. You may want this to be 100% while
* in development and sample at a lower rate in production
*/
replaysSessionSampleRate: 0.1,
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
integrations: [
new Sentry.Replay({
// Additional Replay configuration goes in here, for example:
maskAllText: true,
blockAllMedia: true,
}),
],
});

View file

@ -1,17 +0,0 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever middleware or an Edge route handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});

View file

@ -0,0 +1,18 @@
/*
* This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
* The config you add here will be used whenever one of the edge features is loaded.
* Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
*/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View file

@ -1,17 +0,0 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
Sentry.init({
dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});

View file

@ -0,0 +1,17 @@
/*
* This file configures the initialization of Sentry on the server.
* The config you add here will be used whenever the server handles a request.
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
*/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View file

@ -1,2 +1,3 @@
export { default as AvataxIcon } from "./avatax-icon.svg";
export { default as TaxJarIcon } from "./taxjar-icon.svg";
export { default as StripeTaxIcon } from "./stripe-icon.svg";

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 234 KiB

View file

@ -1,8 +0,0 @@
import React, { PropsWithChildren } from "react";
import dynamic from "next/dynamic";
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
ssr: false,
});

View file

@ -0,0 +1,22 @@
import { Obfuscator } from "./obfuscator";
import { describe, expect, it } from "vitest";
const obfuscator = new Obfuscator();
describe("Obfuscator", () => {
describe("obfuscate", () => {
it("obfuscates all but the last 4 characters of a string", () => {
expect(obfuscator.obfuscate("1234567890")).toBe("******7890");
});
it("returns asterisks even if the string is shorter than 4 characters", () => {
expect(obfuscator.obfuscate("123")).toBe("***");
});
});
describe("isObfuscated", () => {
it("returns true if the string contains 4 asterisks", () => {
expect(obfuscator.isObfuscated("1234****")).toBe(true);
});
it("returns false if the string does not contain 4 asterisks", () => {
expect(obfuscator.isObfuscated("1234567890")).toBe(false);
});
});
});

View file

@ -0,0 +1,16 @@
export class Obfuscator {
obfuscate = (value: string) => {
if (value.length < 4) {
return "*".repeat(value.length);
}
return value.replace(/.(?=.{4})/g, "*");
};
/*
* // ! What if the user password contains "****"? We shouldn't rely on the characters to verify the obfuscation,
* // ! but rather on the context. For example, when updating a provider configuration,
* // ! we could check if the value has changed. If it's the same as returned from the server, then it's obfuscated.
*/
isObfuscated = (value: string) => value.includes("****");
}

View file

@ -1,48 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { AppBridgeState } from "@saleor/app-sdk/app-bridge";
import { render, waitFor } from "@testing-library/react";
import { ThemeSynchronizer } from "./theme-synchronizer";
const appBridgeState: AppBridgeState = {
ready: true,
token: "token",
domain: "some-domain.saleor.cloud",
theme: "dark",
path: "/",
locale: "en",
id: "app-id",
saleorApiUrl: "https://some-domain.saleor.cloud/graphql/",
};
const mockThemeChange = vi.fn();
vi.mock("@saleor/app-sdk/app-bridge", () => {
return {
useAppBridge() {
return {
appBridgeState: appBridgeState,
};
},
};
});
vi.mock("@saleor/macaw-ui", () => {
return {
useTheme() {
return {
setTheme: mockThemeChange,
themeType: "light",
};
},
};
});
describe("ThemeSynchronizer", () => {
it("Updates MacawUI theme when AppBridgeState theme changes", () => {
render(<ThemeSynchronizer />);
return waitFor(() => {
expect(mockThemeChange).toHaveBeenCalledWith("dark");
});
});
});

View file

@ -1,28 +1,25 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui";
import { memo, useEffect } from "react";
import { useTheme } from "@saleor/macaw-ui/next";
import { useEffect } from "react";
/**
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
* Macaw must be informed about this change from AppBridge.
*
* If you are not using Macaw, you can remove this.
*/
function _ThemeSynchronizer() {
// todo move to shared
export function ThemeSynchronizer() {
const { appBridgeState } = useAppBridge();
const { setTheme, themeType } = useTheme();
const { setTheme } = useTheme();
useEffect(() => {
if (!setTheme || !appBridgeState?.theme) {
return;
}
if (themeType !== appBridgeState?.theme) {
setTheme(appBridgeState.theme);
if (appBridgeState.theme === "light") {
setTheme("defaultLight");
}
}, [appBridgeState?.theme, setTheme, themeType]);
if (appBridgeState.theme === "dark") {
setTheme("defaultDark");
}
}, [appBridgeState?.theme, setTheme]);
return null;
}
export const ThemeSynchronizer = memo(_ThemeSynchronizer);

View file

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

View file

@ -1,40 +0,0 @@
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
export interface AppConfigurator<TConfig extends Record<string, any>> {
setConfig(config: TConfig): Promise<void>;
getConfig(): Promise<TConfig | undefined>;
}
export class PrivateMetadataAppConfigurator<TConfig extends Record<string, any>>
implements AppConfigurator<TConfig>
{
constructor(
private metadataManager: SettingsManager,
private saleorApiUrl: string,
private metadataKey: string
) {
this.metadataKey = metadataKey;
}
getConfig(): Promise<TConfig | undefined> {
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
if (!data) {
return data;
}
try {
return JSON.parse(data);
} catch (e) {
throw new Error("Invalid metadata value, cant be parsed");
}
});
}
setConfig(config: TConfig): Promise<void> {
return this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(config),
domain: this.saleorApiUrl,
});
}
}

View file

@ -1,12 +1,12 @@
import { encrypt } from "@saleor/app-sdk/settings-manager";
import { getAppConfig } from "./get-app-config";
import { describe, expect, it, vi } from "vitest";
import { ProvidersConfig } from "../providers-configuration/providers-config";
import { ProviderConnections } from "../provider-connections/provider-connections";
import { MetadataItem } from "../../../generated/graphql";
import { ChannelsConfig } from "../channels-configuration/channels-config";
import { ChannelsConfig } from "../channel-configuration/channel-config";
const mockedSecretKey = "test_secret_key";
const mockedProviders: ProvidersConfig = [
const mockedProviders: ProviderConnections = [
{
provider: "avatax",
id: "1",
@ -15,25 +15,11 @@ const mockedProviders: ProvidersConfig = [
isAutocommit: false,
isSandbox: true,
name: "avatax-1",
shippingTaxCode: "FR000000",
credentials: {
password: "avatax-password",
username: "avatax-username",
shippingTaxCode: "FR000000",
},
},
{
provider: "taxjar",
id: "2",
config: {
name: "taxjar-1",
apiKey: "taxjar-api-key",
isSandbox: true,
},
},
];
const mockedEncryptedProviders = encrypt(JSON.stringify(mockedProviders), mockedSecretKey);
const mockedChannels: ChannelsConfig = {
"default-channel": {
address: {
city: "New York",
country: "US",
@ -41,10 +27,38 @@ const mockedChannels: ChannelsConfig = {
street: "123 Main St",
zip: "10001",
},
enabled: true,
providerInstanceId: "1",
},
};
},
{
provider: "taxjar",
id: "2",
config: {
name: "taxjar-1",
isSandbox: true,
credentials: {
apiKey: "taxjar-api-key",
},
address: {
city: "New York",
country: "US",
state: "NY",
street: "123 Main St",
zip: "10001",
},
},
},
];
const mockedEncryptedProviders = encrypt(JSON.stringify(mockedProviders), mockedSecretKey);
const mockedChannels: ChannelsConfig = [
{
id: "1",
config: {
providerConnectionId: "1",
slug: "default-channel",
},
},
];
const mockedEncryptedChannels = encrypt(JSON.stringify(mockedChannels), mockedSecretKey);
@ -62,17 +76,17 @@ const mockedMetadata: MetadataItem[] = [
vi.stubEnv("SECRET_KEY", mockedSecretKey);
describe("getAppConfig", () => {
it("returns empty providers and channels config when no metadata", () => {
const { providers, channels } = getAppConfig([]);
it("returns empty providerConnections and channels config when no metadata", () => {
const { providerConnections, channels } = getAppConfig([]);
expect(providers).toEqual([]);
expect(providerConnections).toEqual([]);
expect(channels).toEqual({});
});
it("returns decrypted providers and channels config when metadata provided", () => {
const { providers, channels } = getAppConfig(mockedMetadata);
it("returns decrypted providerConnections and channels config when metadata provided", () => {
const { providerConnections, channels } = getAppConfig(mockedMetadata);
expect(providers).toEqual(mockedProviders);
expect(providerConnections).toEqual(mockedProviders);
expect(channels).toEqual(mockedChannels);
});
});

View file

@ -1,10 +1,13 @@
import { decrypt } from "@saleor/app-sdk/settings-manager";
import { MetadataItem } from "../../../generated/graphql";
import { ChannelsConfig, channelsSchema } from "../channels-configuration/channels-config";
import { ProvidersConfig, providersSchema } from "../providers-configuration/providers-config";
import { ChannelsConfig, channelsSchema } from "../channel-configuration/channel-config";
import {
ProviderConnections,
providerConnectionsSchema,
} from "../provider-connections/provider-connections";
export const getAppConfig = (metadata: MetadataItem[]) => {
let providersConfig = [] as ProvidersConfig;
let providerConnections = [] as ProviderConnections;
let channelsConfig = {} as ChannelsConfig;
const secretKey = process.env.SECRET_KEY;
@ -21,10 +24,10 @@ export const getAppConfig = (metadata: MetadataItem[]) => {
const decrypted = decrypt(item.value, secretKey);
const parsed = JSON.parse(decrypted);
const providersValidation = providersSchema.safeParse(parsed);
const providerConnectionValidation = providerConnectionsSchema.safeParse(parsed);
if (providersValidation.success) {
providersConfig = providersValidation.data;
if (providerConnectionValidation.success) {
providerConnections = providerConnectionValidation.data;
return;
}
@ -36,5 +39,5 @@ export const getAppConfig = (metadata: MetadataItem[]) => {
}
});
return { providers: providersConfig, channels: channelsConfig };
return { providerConnections: providerConnections, channels: channelsConfig };
};

View file

@ -1,24 +1,41 @@
import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
import { Client } from "urql";
import { Client, gql } from "urql";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateMetadataDocument,
} from "../../../generated/graphql";
import { createLogger } from "../../lib/logger";
gql`
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
updatePrivateMetadata(id: $id, input: $input) {
item {
privateMetadata {
key
value
}
}
}
}
`;
gql`
query FetchAppDetails {
app {
id
privateMetadata {
key
value
}
}
}
`;
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
const logger = createLogger({ service: "fetchAllMetadata" });
logger.debug("Fetching metadata from Saleor");
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
// * `metadata` name is required for secrets censorship
logger.debug({ error, metadata: data }, "Metadata fetched");
if (error) {
return [];
}
@ -26,29 +43,7 @@ export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]>
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
}
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
const logger = createLogger({ service: "mutateMetadata" });
logger.debug({ metadata }, "Mutating metadata");
// to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
.toPromise();
logger.debug({ error: idQueryError, data: idQueryData }, "Metadata mutated");
if (idQueryError) {
throw new Error(
"Could not fetch the app id. Please check if auth data for the client are valid."
);
}
const appId = idQueryData?.app?.id;
if (!appId) {
throw new Error("Could not fetch the app ID");
}
export async function mutateMetadata(client: Client, metadata: MetadataEntry[], appId: string) {
const { error: mutationError, data: mutationData } = await client
.mutation(UpdateMetadataDocument, {
id: appId,
@ -68,8 +63,8 @@ export async function mutateMetadata(client: Client, metadata: MetadataEntry[])
);
}
export const createSettingsManager = (client: Client) => {
/**
export const createSettingsManager = (client: Client, appId: string) => {
/*
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
* We recommend it for production, because all values are encrypted.
* If your use case require plain text values, you can use MetadataManager.
@ -78,6 +73,6 @@ export const createSettingsManager = (client: Client) => {
// Secret key should be randomly created for production and set as environment variable
encryptionKey: process.env.SECRET_KEY!,
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutateMetadata(client, metadata),
mutateMetadata: (metadata) => mutateMetadata(client, metadata, appId),
});
};

View file

@ -9,12 +9,12 @@ export class WebhookResponse {
}
private returnSuccess(data?: unknown) {
this.logger.debug({ data }, "success called with:");
this.logger.debug({ data }, "Responding to Saleor with data:");
return this.res.status(200).json(data ?? {});
}
private returnError(errorMessage: string) {
this.logger.debug({ errorMessage }, "returning error:");
this.logger.debug({ errorMessage }, "Responding to Saleor with error:");
return this.res.status(500).json({ error: errorMessage });
}

View file

@ -1,8 +1,8 @@
import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo";
import { ChannelAddress } from "../channels-configuration/channels-config";
import { AddressFragment as SaleorAddress } from "../../../generated/graphql";
import { AvataxConfig } from "./avatax-connection-schema";
import { AddressFragment } from "../../../generated/graphql";
function mapSaleorAddressToAvataxAddress(address: SaleorAddress): AvataxAddress {
function mapSaleorAddressToAvataxAddress(address: AddressFragment): AvataxAddress {
return {
line1: address.streetAddress1,
line2: address.streetAddress2,
@ -13,7 +13,7 @@ function mapSaleorAddressToAvataxAddress(address: SaleorAddress): AvataxAddress
};
}
function mapChannelAddressToAvataxAddress(address: ChannelAddress): AvataxAddress {
function mapChannelAddressToAvataxAddress(address: AvataxConfig["address"]): AvataxAddress {
return {
line1: address.street,
city: address.city,

View file

@ -2,7 +2,7 @@ import Avatax from "avatax";
import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel";
import packageJson from "../../../package.json";
import { createLogger, Logger } from "../../lib/logger";
import { AvataxConfig } from "./avatax-config";
import { AvataxConfig } from "./avatax-connection-schema";
import { CommitTransactionModel } from "avatax/lib/models/CommitTransactionModel";
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo";
@ -57,54 +57,22 @@ export class AvataxClient {
private logger: Logger;
constructor(config: AvataxConfig) {
this.logger = createLogger({ service: "AvataxClient" });
this.logger.trace("AvataxClient constructor");
const { username, password } = config;
const credentials = {
username,
password,
};
this.logger = createLogger({ location: "AvataxClient" });
const settings = createAvataxSettings(config);
const avataxClient = new Avatax(settings).withSecurity(credentials);
const avataxClient = new Avatax(settings).withSecurity(config.credentials);
this.logger.trace({ client: avataxClient }, "External Avatax client created");
this.client = avataxClient;
}
async createTransaction({ model }: CreateTransactionArgs) {
this.logger.trace({ model }, "createTransaction called with:");
return this.client.createTransaction({ model });
}
async commitTransaction(args: CommitTransactionArgs) {
this.logger.trace(args, "commitTransaction called with:");
return this.client.commitTransaction(args);
}
async ping() {
this.logger.trace("ping called");
try {
const result = await this.client.ping();
return {
authenticated: result.authenticated,
...(!result.authenticated && {
error: "Avatax was not able to authenticate with the provided credentials.",
}),
};
} catch (error) {
return {
authenticated: false,
error: "Avatax was not able to authenticate with the provided credentials.",
};
}
}
async validateAddress({ address }: ValidateAddressArgs) {
this.logger.trace({ address }, "validateAddress called with:");
return this.client.resolveAddress(address);
}
}

View file

@ -0,0 +1,34 @@
import { AvataxConfig } from "./avatax-connection-schema";
const defaultAvataxConfig: AvataxConfig = {
companyCode: "DEFAULT",
isAutocommit: false,
isSandbox: true,
name: "Avatax-1",
shippingTaxCode: "FR000000",
address: {
country: "US",
zip: "95008",
state: "CA",
city: "Campbell",
street: "33 N. First Street",
},
credentials: {
password: "password",
username: "username",
},
};
const testingScenariosMap = {
default: defaultAvataxConfig,
};
export class AvataxConfigMockGenerator {
constructor(private scenario: keyof typeof testingScenariosMap = "default") {}
generateAvataxConfig = (overrides: Partial<AvataxConfig> = {}): AvataxConfig =>
structuredClone({
...testingScenariosMap[this.scenario],
...overrides,
});
}

View file

@ -0,0 +1,61 @@
import { AvataxConfigMockGenerator } from "./avatax-config-mock-generator";
import { AvataxConnectionObfuscator } from "./avatax-connection-obfuscator";
import { expect, it, describe } from "vitest";
const mockAvataxConfig = new AvataxConfigMockGenerator().generateAvataxConfig();
const obfuscator = new AvataxConnectionObfuscator();
describe("AvataxConnectionObfuscator", () => {
it("obfuscated avatax config", () => {
const obfuscatedConfig = obfuscator.obfuscateAvataxConfig(mockAvataxConfig);
expect(obfuscatedConfig).toEqual({
...mockAvataxConfig,
credentials: {
password: "****word",
username: "****name",
},
});
});
it("filters out obfuscated", () => {
const obfuscatedConfig = obfuscator.obfuscateAvataxConfig(mockAvataxConfig);
const { credentials, ...rest } = obfuscatedConfig;
const filteredConfig = obfuscator.filterOutObfuscated(obfuscatedConfig);
expect(filteredConfig).toEqual(rest);
});
it("filters out username when is obfuscated", () => {
const filteredConfig = obfuscator.filterOutObfuscated({
...mockAvataxConfig,
credentials: {
password: "password",
username: "****name",
},
});
expect(filteredConfig).toEqual({
...mockAvataxConfig,
credentials: {
password: "password",
},
});
});
it("filters out password when is obfuscated", () => {
const filteredConfig = obfuscator.filterOutObfuscated({
...mockAvataxConfig,
credentials: {
password: "****word",
username: "username",
},
});
expect(filteredConfig).toEqual({
...mockAvataxConfig,
credentials: {
username: "username",
},
});
});
});

View file

@ -1,104 +0,0 @@
import { z } from "zod";
import { createLogger } from "../../lib/logger";
import { isObfuscated } from "../../lib/utils";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server";
import { avataxConfigSchema, obfuscateAvataxConfig } from "./avatax-config";
import { AvataxConfigurationService } from "./avatax-configuration.service";
const getInputSchema = z.object({
id: z.string(),
});
const deleteInputSchema = z.object({
id: z.string(),
});
const patchInputSchema = z.object({
id: z.string(),
value: avataxConfigSchema.partial().transform((c) => {
const { username, password, ...config } = c ?? {};
return {
...config,
...(username && !isObfuscated(username) && { username }),
...(password && !isObfuscated(password) && { password }),
};
}),
});
const postInputSchema = z.object({
value: avataxConfigSchema,
});
export const avataxConfigurationRouter = router({
get: protectedClientProcedure.input(getInputSchema).query(async ({ ctx, input }) => {
const logger = createLogger({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "avataxConfigurationRouter.get",
});
logger.debug({ input }, "avataxConfigurationRouter.get called with:");
const { apiClient, saleorApiUrl } = ctx;
const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl);
const result = await avataxConfigurationService.get(input.id);
// * `providerInstance` name is required for secrets censorship
logger.debug({ providerInstance: result }, "avataxConfigurationRouter.get finished");
return { ...result, config: obfuscateAvataxConfig(result.config) };
}),
post: protectedClientProcedure.input(postInputSchema).mutation(async ({ ctx, input }) => {
const logger = createLogger({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "avataxConfigurationRouter.post",
});
logger.debug({ input }, "avataxConfigurationRouter.post called with:");
const { apiClient, saleorApiUrl } = ctx;
const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl);
const result = await avataxConfigurationService.post(input.value);
logger.debug({ result }, "avataxConfigurationRouter.post finished");
return result;
}),
delete: protectedClientProcedure.input(deleteInputSchema).mutation(async ({ ctx, input }) => {
const logger = createLogger({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "avataxConfigurationRouter.delete",
});
logger.debug({ input }, "avataxConfigurationRouter.delete called with:");
const { apiClient, saleorApiUrl } = ctx;
const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl);
const result = await avataxConfigurationService.delete(input.id);
logger.debug({ result }, "avataxConfigurationRouter.delete finished");
return result;
}),
patch: protectedClientProcedure.input(patchInputSchema).mutation(async ({ ctx, input }) => {
const logger = createLogger({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "avataxConfigurationRouter.patch",
});
logger.debug({ input }, "avataxConfigurationRouter.patch called with:");
const { apiClient, saleorApiUrl } = ctx;
const avataxConfigurationService = new AvataxConfigurationService(apiClient, saleorApiUrl);
const result = await avataxConfigurationService.patch(input.id, input.value);
logger.debug({ result }, "avataxConfigurationRouter.patch finished");
return result;
}),
});

View file

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

View file

@ -0,0 +1,44 @@
import { Obfuscator } from "../../lib/obfuscator";
import { AvataxConfig, AvataxConnection } from "./avatax-connection-schema";
export class AvataxConnectionObfuscator {
private obfuscator = new Obfuscator();
obfuscateAvataxConfig = (config: AvataxConfig): AvataxConfig => {
return {
...config,
credentials: {
...config.credentials,
username: this.obfuscator.obfuscate(config.credentials.username),
password: this.obfuscator.obfuscate(config.credentials.password),
},
};
};
obfuscateAvataxConnection = (connection: AvataxConnection): AvataxConnection => ({
...connection,
config: this.obfuscateAvataxConfig(connection.config),
});
obfuscateAvataxConnections = (connections: AvataxConnection[]): AvataxConnection[] =>
connections.map((connection) => this.obfuscateAvataxConnection(connection));
filterOutObfuscated = (data: AvataxConfig) => {
const { credentials, ...rest } = data;
const isPasswordObfuscated = this.obfuscator.isObfuscated(credentials.password);
const isUsernameObfuscated = this.obfuscator.isObfuscated(credentials.username);
const isBothObfuscated = isPasswordObfuscated && isUsernameObfuscated;
if (isBothObfuscated) {
return rest;
}
return {
...rest,
credentials: {
...(!isPasswordObfuscated && { password: credentials.password }),
...(!isUsernameObfuscated && { username: credentials.username }),
},
};
};
}

View file

@ -1,44 +1,53 @@
import { z } from "zod";
import { obfuscateSecret } from "../../lib/utils";
const addressSchema = z.object({
country: z.string(),
zip: z.string(),
state: z.string(),
city: z.string(),
street: z.string(),
});
const avataxCredentialsSchema = z.object({
username: z.string().min(1, { message: "Username requires at least one character." }),
password: z.string().min(1, { message: "Password requires at least one character." }),
});
export const avataxConfigSchema = z.object({
name: z.string().min(1, { message: "Name requires at least one character." }),
username: z.string().min(1, { message: "Username requires at least one character." }),
password: z.string().min(1, { message: "Password requires at least one character." }),
isSandbox: z.boolean(),
companyCode: z.string().optional(),
isAutocommit: z.boolean(),
shippingTaxCode: z.string().optional(),
credentials: avataxCredentialsSchema,
address: addressSchema,
});
export type AvataxConfig = z.infer<typeof avataxConfigSchema>;
export const defaultAvataxConfig: AvataxConfig = {
name: "",
username: "",
password: "",
companyCode: "",
isSandbox: true,
isSandbox: false,
isAutocommit: false,
shippingTaxCode: "",
credentials: {
username: "",
password: "",
},
address: {
city: "",
country: "",
state: "",
street: "",
zip: "",
},
};
export const avataxInstanceConfigSchema = z.object({
export const avataxConnectionSchema = z.object({
id: z.string(),
provider: z.literal("avatax"),
config: avataxConfigSchema,
});
export type AvataxInstanceConfig = z.infer<typeof avataxInstanceConfigSchema>;
export const obfuscateAvataxConfig = (config: AvataxConfig) => ({
...config,
username: obfuscateSecret(config.username),
password: obfuscateSecret(config.password),
});
export const obfuscateAvataxInstances = (instances: AvataxInstanceConfig[]) =>
instances.map((instance) => ({
...instance,
config: obfuscateAvataxConfig(instance.config),
}));
export type AvataxConnection = z.infer<typeof avataxConnectionSchema>;

View file

@ -0,0 +1,100 @@
import { z } from "zod";
import { createLogger } from "../../lib/logger";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server";
import { avataxConfigSchema } from "./avatax-connection-schema";
import { PublicAvataxConnectionService } from "./configuration/public-avatax-connection.service";
const getInputSchema = z.object({
id: z.string(),
});
const deleteInputSchema = z.object({
id: z.string(),
});
const patchInputSchema = z.object({
id: z.string(),
value: avataxConfigSchema.deepPartial(),
});
const postInputSchema = z.object({
value: avataxConfigSchema,
});
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
next({
ctx: {
...ctx,
connectionService: new PublicAvataxConnectionService(
ctx.apiClient,
ctx.appId!,
ctx.saleorApiUrl
),
},
})
);
export const avataxConnectionRouter = router({
getById: protectedWithConfigurationService.input(getInputSchema).query(async ({ ctx, input }) => {
const logger = createLogger({
location: "avataxConnectionRouter.get",
});
logger.debug("Route get called");
const result = await ctx.connectionService.getById(input.id);
logger.info(`Avatax configuration with an id: ${result.id} was successfully retrieved`);
return result;
}),
create: protectedWithConfigurationService
.input(postInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "avataxConnectionRouter.post",
});
logger.debug("Attempting to create configuration");
const result = await ctx.connectionService.create(input.value);
logger.info("Avatax configuration was successfully created");
return result;
}),
delete: protectedWithConfigurationService
.input(deleteInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "avataxConnectionRouter.delete",
});
logger.debug("Route delete called");
const result = await ctx.connectionService.delete(input.id);
logger.info(`Avatax configuration with an id: ${input.id} was deleted`);
return result;
}),
update: protectedWithConfigurationService
.input(patchInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "avataxConnectionRouter.patch",
});
logger.debug("Route patch called");
const result = await ctx.connectionService.update(input.id, input.value);
logger.info(`Avatax configuration with an id: ${input.id} was successfully updated`);
return result;
}),
});

View file

@ -1,5 +1,6 @@
import { avataxMockTransactionFactory } from "./avatax-mock-transaction-factory";
// todo: refactor to mockGenerator
export const avataxMockFactory = {
...avataxMockTransactionFactory,
};

View file

@ -4,10 +4,9 @@ import {
TaxBaseFragment,
} from "../../../generated/graphql";
import { Logger, createLogger } from "../../lib/logger";
import { ChannelConfig } from "../channels-configuration/channels-config";
import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
import { AvataxClient } from "./avatax-client";
import { AvataxConfig, defaultAvataxConfig } from "./avatax-config";
import { AvataxConfig, defaultAvataxConfig } from "./avatax-connection-schema";
import { AvataxCalculateTaxesAdapter } from "./calculate-taxes/avatax-calculate-taxes-adapter";
import { AvataxOrderCreatedAdapter } from "./order-created/avatax-order-created-adapter";
import { AvataxOrderFulfilledAdapter } from "./order-fulfilled/avatax-order-fulfilled-adapter";
@ -19,48 +18,35 @@ export class AvataxWebhookService implements ProviderWebhookService {
constructor(config: AvataxConfig) {
this.logger = createLogger({
service: "AvataxWebhookService",
location: "AvataxWebhookService",
});
const avataxClient = new AvataxClient(config);
this.logger.trace({ client: avataxClient }, "Internal Avatax client created");
this.config = config;
this.client = avataxClient;
}
async calculateTaxes(taxBase: TaxBaseFragment, channelConfig: ChannelConfig) {
this.logger.debug({ taxBase, channelConfig }, "calculateTaxes called with:");
async calculateTaxes(taxBase: TaxBaseFragment) {
const adapter = new AvataxCalculateTaxesAdapter(this.config);
const response = await adapter.send({ channelConfig, taxBase });
this.logger.debug({ response }, "calculateTaxes response:");
const response = await adapter.send({ taxBase });
return response;
}
async createOrder(order: OrderCreatedSubscriptionFragment, channelConfig: ChannelConfig) {
this.logger.debug({ order, channelConfig }, "createOrder called with:");
async createOrder(order: OrderCreatedSubscriptionFragment) {
const adapter = new AvataxOrderCreatedAdapter(this.config);
const response = await adapter.send({ channelConfig, order });
this.logger.debug({ response }, "createOrder response:");
const response = await adapter.send({ order });
return response;
}
async fulfillOrder(order: OrderFulfilledSubscriptionFragment, channelConfig: ChannelConfig) {
this.logger.debug({ order, channelConfig }, "fulfillOrder called with:");
async fulfillOrder(order: OrderFulfilledSubscriptionFragment) {
const adapter = new AvataxOrderFulfilledAdapter(this.config);
const response = await adapter.send({ order });
this.logger.debug({ response }, "fulfillOrder response:");
return response;
}
}

View file

@ -1,39 +1,43 @@
import { TaxBaseFragment } from "../../../../generated/graphql";
import { Logger, createLogger } from "../../../lib/logger";
import { ChannelConfig } from "../../channels-configuration/channels-config";
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { AvataxClient, CreateTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-config";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxCalculateTaxesPayloadTransformer } from "./avatax-calculate-taxes-payload-transformer";
import { AvataxCalculateTaxesResponseTransformer } from "./avatax-calculate-taxes-response-transformer";
export const SHIPPING_ITEM_CODE = "Shipping";
export type Payload = {
export type AvataxCalculateTaxesPayload = {
taxBase: TaxBaseFragment;
channelConfig: ChannelConfig;
config: AvataxConfig;
};
export type Target = CreateTransactionArgs;
export type Response = CalculateTaxesResponse;
export type AvataxCalculateTaxesTarget = CreateTransactionArgs;
export type AvataxCalculateTaxesResponse = CalculateTaxesResponse;
export class AvataxCalculateTaxesAdapter implements WebhookAdapter<Payload, Response> {
export class AvataxCalculateTaxesAdapter
implements WebhookAdapter<AvataxCalculateTaxesPayload, AvataxCalculateTaxesResponse>
{
private logger: Logger;
constructor(private readonly config: AvataxConfig) {
this.logger = createLogger({ service: "AvataxCalculateTaxesAdapter" });
this.logger = createLogger({ location: "AvataxCalculateTaxesAdapter" });
}
async send(payload: Pick<Payload, "channelConfig" | "taxBase">): Promise<Response> {
this.logger.debug({ payload }, "send called with:");
async send(payload: AvataxCalculateTaxesPayload): Promise<AvataxCalculateTaxesResponse> {
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
const payloadTransformer = new AvataxCalculateTaxesPayloadTransformer();
const target = payloadTransformer.transform({ ...payload, config: this.config });
const target = payloadTransformer.transform({ ...payload, providerConfig: this.config });
this.logger.debug(
{ transformedPayload: target },
"Will call Avatax createTransaction with transformed payload:"
);
const client = new AvataxClient(this.config);
const response = await client.createTransaction(target);
this.logger.debug({ response }, "Avatax createTransaction response:");
this.logger.debug({ response }, "Avatax createTransaction responded with:");
const responseTransformer = new AvataxCalculateTaxesResponseTransformer();
const transformedResponse = responseTransformer.transform(response);

View file

@ -1,6 +1,6 @@
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { ChannelConfig } from "../../channels-configuration/channels-config";
import { ChannelConfig } from "../../channel-configuration/channel-config";
import { DocumentStatus } from "avatax/lib/enums/DocumentStatus";
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { AdjustmentReason } from "avatax/lib/enums/AdjustmentReason";
@ -10,7 +10,9 @@ import { RateType } from "avatax/lib/enums/RateType";
import { ChargedTo } from "avatax/lib/enums/ChargedTo";
import { JurisdictionType } from "avatax/lib/enums/JurisdictionType";
import { BoundaryLevel } from "avatax/lib/enums/BoundaryLevel";
import { AvataxConfig } from "../avatax-config";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxConfigMockGenerator } from "../avatax-config-mock-generator";
import { ChannelConfigMockGenerator } from "../../channel-configuration/channel-config-mock-generator";
type TaxBase = TaxBaseFragment;
@ -109,18 +111,6 @@ const defaultTaxBase: TaxBase = {
},
};
const defaultChannelConfig: ChannelConfig = {
providerInstanceId: "b8c29f49-7cae-4762-8458-e9a27eb83081",
enabled: false,
address: {
country: "US",
zip: "92093",
state: "CA",
city: "La Jolla",
street: "9500 Gilman Drive",
},
};
const defaultTransactionModel: TransactionModel = {
id: 0,
code: "aec372bb-f3b3-40fb-9d84-2b46cd67e516",
@ -943,21 +933,9 @@ const defaultTransactionModel: TransactionModel = {
],
};
const defaultAvataxConfig: AvataxConfig = {
companyCode: "DEFAULT",
isAutocommit: false,
isSandbox: true,
name: "Avatax-1",
password: "password",
username: "username",
shippingTaxCode: "FR000000",
};
const testingScenariosMap = {
default: {
taxBase: defaultTaxBase,
channelConfig: defaultChannelConfig,
avataxConfig: defaultAvataxConfig,
response: defaultTransactionModel,
},
};
@ -972,17 +950,17 @@ export class AvataxCalculateTaxesMockGenerator {
...overrides,
});
generateChannelConfig = (overrides: Partial<ChannelConfig> = {}): ChannelConfig =>
structuredClone({
...testingScenariosMap[this.scenario].channelConfig,
...overrides,
});
generateChannelConfig = (overrides: Partial<ChannelConfig> = {}): ChannelConfig => {
const mockGenerator = new ChannelConfigMockGenerator();
generateAvataxConfig = (overrides: Partial<AvataxConfig> = {}): AvataxConfig =>
structuredClone({
...testingScenariosMap[this.scenario].avataxConfig,
...overrides,
});
return mockGenerator.generateChannelConfig(overrides);
};
generateAvataxConfig = (overrides: Partial<AvataxConfig> = {}): AvataxConfig => {
const mockGenerator = new AvataxConfigMockGenerator();
return mockGenerator.generateAvataxConfig(overrides);
};
generateResponse = (overrides: Partial<TransactionModel> = {}): TransactionModel =>
structuredClone({

View file

@ -13,8 +13,7 @@ describe("AvataxCalculateTaxesPayloadTransformer", () => {
const payload = new AvataxCalculateTaxesPayloadTransformer().transform({
taxBase: taxBaseMock,
channelConfig: mockGenerator.generateChannelConfig(),
config: avataxConfigMock,
providerConfig: avataxConfigMock,
});
expect(payload.model.discount).toEqual(10);
@ -26,8 +25,7 @@ describe("AvataxCalculateTaxesPayloadTransformer", () => {
const payload = new AvataxCalculateTaxesPayloadTransformer().transform({
taxBase: taxBaseMock,
channelConfig: mockGenerator.generateChannelConfig(),
config: avataxConfigMock,
providerConfig: avataxConfigMock,
});
expect(payload.model.discount).toEqual(0);

View file

@ -1,10 +1,11 @@
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { LineItemModel } from "avatax/lib/models/LineItemModel";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { AvataxConfig } from "../avatax-config";
import { avataxAddressFactory } from "../address-factory";
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { SHIPPING_ITEM_CODE, Payload, Target } from "./avatax-calculate-taxes-adapter";
import { discountUtils } from "../../taxes/discount-utils";
import { avataxAddressFactory } from "../address-factory";
import { CreateTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-connection-schema";
import { SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter";
export function mapPayloadLines(taxBase: TaxBaseFragment, config: AvataxConfig): LineItemModel[] {
const isDiscounted = taxBase.discounts.length > 0;
@ -35,22 +36,26 @@ export function mapPayloadLines(taxBase: TaxBaseFragment, config: AvataxConfig):
}
export class AvataxCalculateTaxesPayloadTransformer {
transform(props: Payload): Target {
const { taxBase, channelConfig, config } = props;
transform({
taxBase,
providerConfig,
}: {
taxBase: TaxBaseFragment;
providerConfig: AvataxConfig;
}): CreateTransactionArgs {
return {
model: {
type: DocumentType.SalesOrder,
customerCode: taxBase.sourceObject.user?.id ?? "",
companyCode: config.companyCode,
companyCode: providerConfig.companyCode,
// * commit: If true, the transaction will be committed immediately after it is created. See: https://developer.avalara.com/communications/dev-guide_rest_v2/commit-uncommit
commit: config.isAutocommit,
commit: providerConfig.isAutocommit,
addresses: {
shipFrom: avataxAddressFactory.fromChannelAddress(channelConfig.address),
shipFrom: avataxAddressFactory.fromChannelAddress(providerConfig.address),
shipTo: avataxAddressFactory.fromSaleorAddress(taxBase.address!),
},
currencyCode: taxBase.currency,
lines: mapPayloadLines(taxBase, config),
lines: mapPayloadLines(taxBase, providerConfig),
date: new Date(),
discount: discountUtils.sumDiscounts(
taxBase.discounts.map((discount) => discount.amount.amount)

View file

@ -1,10 +1,11 @@
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { numbers } from "../../taxes/numbers";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
import { Response, SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter";
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
import { SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter";
export class AvataxCalculateTaxesResponseLinesTransformer {
transform(transaction: TransactionModel): Response["lines"] {
transform(transaction: TransactionModel): CalculateTaxesResponse["lines"] {
const productLines = transaction.lines?.filter((line) => line.itemCode !== SHIPPING_ITEM_CODE);
return (

View file

@ -1,13 +1,14 @@
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { numbers } from "../../taxes/numbers";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
import { Response, SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter";
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
import { SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter";
export class AvataxCalculateTaxesResponseShippingTransformer {
transform(
transaction: TransactionModel
): Pick<
Response,
CalculateTaxesResponse,
"shipping_price_gross_amount" | "shipping_price_net_amount" | "shipping_tax_rate"
> {
const shippingLine = transaction.lines?.find((line) => line.itemCode === SHIPPING_ITEM_CODE);

View file

@ -1,10 +1,10 @@
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { Response } from "./avatax-calculate-taxes-adapter";
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
import { AvataxCalculateTaxesResponseLinesTransformer } from "./avatax-calculate-taxes-response-lines-transformer";
import { AvataxCalculateTaxesResponseShippingTransformer } from "./avatax-calculate-taxes-response-shipping-transformer";
export class AvataxCalculateTaxesResponseTransformer {
transform(response: TransactionModel): Response {
transform(response: TransactionModel): CalculateTaxesResponse {
const shippingTransformer = new AvataxCalculateTaxesResponseShippingTransformer();
const shipping = shippingTransformer.transform(response);

View file

@ -0,0 +1,93 @@
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
import { TaxProvidersV1toV2MigrationManager } from "../../../../scripts/migrations/tax-providers-migration-v1-to-v2";
import { createLogger, Logger } from "../../../lib/logger";
import { CrudSettingsManager } from "../../crud-settings/crud-settings.service";
import {
ProviderConnections,
providerConnectionsSchema,
} from "../../provider-connections/provider-connections";
import { TAX_PROVIDER_KEY } from "../../provider-connections/public-provider-connections.service";
import {
AvataxConfig,
AvataxConnection,
avataxConnectionSchema,
} from "../avatax-connection-schema";
const getSchema = avataxConnectionSchema;
export class AvataxConnectionRepository {
private crudSettingsManager: CrudSettingsManager;
private logger: Logger;
constructor(private settingsManager: EncryptedMetadataManager, private saleorApiUrl: string) {
this.crudSettingsManager = new CrudSettingsManager(
settingsManager,
saleorApiUrl,
TAX_PROVIDER_KEY
);
this.logger = createLogger({
location: "AvataxConnectionRepository",
metadataKey: TAX_PROVIDER_KEY,
});
}
private filterAvataxConnections(connections: ProviderConnections): AvataxConnection[] {
return connections.filter(
(connection) => connection.provider === "avatax"
) as AvataxConnection[];
}
async getAll(): Promise<AvataxConnection[]> {
const { data } = await this.crudSettingsManager.readAll();
/*
* * migration logic start
* // todo: remove after migration
*/
const migrationManager = new TaxProvidersV1toV2MigrationManager(
this.settingsManager,
this.saleorApiUrl
);
const migratedConfig = await migrationManager.migrateIfNeeded();
if (migratedConfig) {
this.logger.info("Config migrated", migratedConfig);
return this.filterAvataxConnections(migratedConfig);
}
this.logger.info("Config is up to date, no need to migrate.");
/*
* * migration logic end
*/
const connections = providerConnectionsSchema.parse(data);
const avataxConnections = this.filterAvataxConnections(connections);
return avataxConnections;
}
async get(id: string): Promise<AvataxConnection> {
const { data } = await this.crudSettingsManager.read(id);
const connection = getSchema.parse(data);
return connection;
}
async post(config: AvataxConfig): Promise<{ id: string }> {
const result = await this.crudSettingsManager.create({
provider: "avatax",
config: config,
});
return result.data;
}
async patch(id: string, input: AvataxConfig): Promise<void> {
return this.crudSettingsManager.update(id, input);
}
async delete(id: string): Promise<void> {
return this.crudSettingsManager.delete(id);
}
}

View file

@ -0,0 +1,59 @@
import { Client } from "urql";
import { Logger, createLogger } from "../../../lib/logger";
import { AvataxConnectionRepository } from "./avatax-connection-repository";
import { AvataxConfig, AvataxConnection } from "../avatax-connection-schema";
import { AvataxValidationService } from "./avatax-validation.service";
import { DeepPartial } from "@trpc/server";
import { PatchInputTransformer } from "../../provider-connections/patch-input-transformer";
import { AuthData } from "@saleor/app-sdk/APL";
import { createSettingsManager } from "../../app/metadata-manager";
export class AvataxConnectionService {
private logger: Logger;
private avataxConnectionRepository: AvataxConnectionRepository;
constructor(client: Client, appId: string, saleorApiUrl: string) {
this.logger = createLogger({
location: "AvataxConnectionService",
});
const settingsManager = createSettingsManager(client, appId);
this.avataxConnectionRepository = new AvataxConnectionRepository(settingsManager, saleorApiUrl);
}
getAll(): Promise<AvataxConnection[]> {
return this.avataxConnectionRepository.getAll();
}
getById(id: string): Promise<AvataxConnection> {
return this.avataxConnectionRepository.get(id);
}
async create(config: AvataxConfig): Promise<{ id: string }> {
const validationService = new AvataxValidationService();
await validationService.validate(config);
return await this.avataxConnectionRepository.post(config);
}
async update(id: string, nextConfigPartial: DeepPartial<AvataxConfig>): Promise<void> {
const data = await this.getById(id);
// omit the key "id" from the result
const { id: _, ...setting } = data;
const prevConfig = setting.config;
const validationService = new AvataxValidationService();
const inputTransformer = new PatchInputTransformer();
const input = inputTransformer.transform(nextConfigPartial, prevConfig);
await validationService.validate(input);
return this.avataxConnectionRepository.patch(id, input);
}
async delete(id: string): Promise<void> {
return this.avataxConnectionRepository.delete(id);
}
}

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { AvataxValidationErrorResolver } from "./avatax-validation-error-resolver";
describe("AvataxValidationErrorResolver", () => {
const errorResolver = new AvataxValidationErrorResolver();
it("when AuthenticationException error, should return error with specific message", () => {
const result = errorResolver.resolve({
code: "AuthenticationException",
details: [
{
message: "message 1",
description: "description 1",
helpLink: "helpLink 1",
code: "code 1",
faultCode: "faultCode 1",
},
{
message: "message 2",
description: "description 2",
helpLink: "helpLink 2",
code: "code 2",
faultCode: "faultCode 2",
},
],
});
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe("Invalid Avatax credentials.");
});
it("when other Avatax error, should return error with first message", () => {
const result = errorResolver.resolve({
code: "error",
details: [
{
message: "message 1",
description: "description 1",
helpLink: "helpLink 1",
code: "code 1",
faultCode: "faultCode 1",
},
{
message: "message 2",
description: "description 2",
helpLink: "helpLink 2",
code: "code 2",
faultCode: "faultCode 2",
},
],
});
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe("message 1");
});
it("when unknown error, should return error with generic message", () => {
const result = errorResolver.resolve("error");
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe("Unknown error while validating Avatax configuration.");
});
});

View file

@ -0,0 +1,47 @@
import { z } from "zod";
import { createLogger, Logger } from "../../../lib/logger";
const avataxErrorSchema = z.object({
code: z.string(),
details: z.array(
z.object({
description: z.string(),
helpLink: z.string(),
code: z.string(),
message: z.string(),
faultCode: z.string(),
})
),
});
export class AvataxValidationErrorResolver {
private logger: Logger;
constructor() {
this.logger = createLogger({
locataion: "AvataxValidationErrorResolver",
});
}
resolve(error: unknown): Error {
const parseResult = avataxErrorSchema.safeParse(error);
const isErrorParsed = parseResult.success;
// Avatax doesn't return a type for their error format, so we need to parse the error
if (isErrorParsed) {
const { code, details } = parseResult.data;
if (code === "AuthenticationException") {
return new Error("Invalid Avatax credentials.");
}
return new Error(details[0].message);
}
if (error instanceof Error) {
return error;
}
this.logger.error("Unknown error while validating Avatax configuration.");
return new Error("Unknown error while validating Avatax configuration.");
}
}

View file

@ -0,0 +1,59 @@
import { AddressResolutionModel } from "avatax/lib/models/AddressResolutionModel";
import { describe, expect, it } from "vitest";
import { AvataxValidationResponseResolver } from "./avatax-validation-response-resolver";
import { ResolutionQuality } from "avatax/lib/enums/ResolutionQuality";
import { JurisdictionType } from "avatax/lib/enums/JurisdictionType";
const mockFailedValidationResponse: AddressResolutionModel = {
address: {
line1: "2000 Main Street",
city: "Irvine",
region: "CA",
country: "US",
postalCode: "92614",
},
coordinates: {
latitude: 33.684884,
longitude: -117.851321,
},
resolutionQuality: ResolutionQuality.Intersection,
taxAuthorities: [
{
avalaraId: "AGAM",
jurisdictionName: "CALIFORNIA",
jurisdictionType: JurisdictionType.State,
signatureCode: "AGAM",
},
],
messages: [
{
summary: "The address is not deliverable.",
details:
"The physical location exists but there are no homes on this street. One reason might be railroad tracks or rivers running alongside this street, as they would prevent construction of homes in this location.",
refersTo: "address",
severity: "Error",
source: "Avalara.AvaTax.Services.Address",
},
],
};
const mockSuccessfulValidationResponse: AddressResolutionModel = {
...mockFailedValidationResponse,
messages: [],
};
describe("AvataxValidationResponseResolver", () => {
const responseResolver = new AvataxValidationResponseResolver();
it("should throw error when messages", () => {
expect(() => responseResolver.resolve(mockFailedValidationResponse)).toThrow();
});
it("should not throw error when no messages", () => {
expect(() => responseResolver.resolve(mockSuccessfulValidationResponse)).not.toThrow();
});
it("should not return anything when no messages", () => {
expect(responseResolver.resolve(mockSuccessfulValidationResponse)).toBeUndefined();
});
});

View file

@ -0,0 +1,11 @@
import { AddressResolutionModel } from "avatax/lib/models/AddressResolutionModel";
export class AvataxValidationResponseResolver {
resolve(response: AddressResolutionModel) {
if (response.messages && response.messages.length > 0) {
throw new Error(
"The provided address is invalid. Please visit https://developer.avalara.com/avatax/address-validation/ to learn about address formatting."
);
}
}
}

View file

@ -0,0 +1,34 @@
import { z } from "zod";
import { createLogger, Logger } from "../../../lib/logger";
import { avataxAddressFactory } from "../address-factory";
import { AvataxClient } from "../avatax-client";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxValidationResponseResolver } from "./avatax-validation-response-resolver";
import { AvataxValidationErrorResolver } from "./avatax-validation-error-resolver";
export class AvataxValidationService {
private logger: Logger;
constructor() {
this.logger = createLogger({
location: "AvataxValidationService",
});
}
async validate(config: AvataxConfig): Promise<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

@ -0,0 +1,38 @@
import { DeepPartial } from "@trpc/server";
import { Client } from "urql";
import { AvataxConnectionObfuscator } from "../avatax-connection-obfuscator";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxConnectionService } from "./avatax-connection.service";
export class PublicAvataxConnectionService {
private readonly connectionService: AvataxConnectionService;
private readonly obfuscator: AvataxConnectionObfuscator;
constructor(client: Client, appId: string, saleorApiUrl: string) {
this.connectionService = new AvataxConnectionService(client, appId, saleorApiUrl);
this.obfuscator = new AvataxConnectionObfuscator();
}
async getAll() {
const connections = await this.connectionService.getAll();
return this.obfuscator.obfuscateAvataxConnections(connections);
}
async getById(id: string) {
const connection = await this.connectionService.getById(id);
return this.obfuscator.obfuscateAvataxConnection(connection);
}
async create(config: AvataxConfig) {
return this.connectionService.create(config);
}
async update(id: string, config: DeepPartial<AvataxConfig>) {
return this.connectionService.update(id, config);
}
async delete(id: string) {
return this.connectionService.delete(id);
}
}

View file

@ -1,38 +1,41 @@
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { ChannelConfig } from "../../channels-configuration/channels-config";
import { Logger, createLogger } from "../../../lib/logger";
import { CreateOrderResponse } from "../../taxes/tax-provider-webhook";
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { AvataxClient, CreateTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-config";
import { AvataxOrderCreatedResponseTransformer } from "./avatax-order-created-response-transformer";
import { AvataxClient } from "../avatax-client";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxOrderCreatedPayloadTransformer } from "./avatax-order-created-payload-transformer";
import { Logger, createLogger } from "../../../lib/logger";
import { AvataxOrderCreatedResponseTransformer } from "./avatax-order-created-response-transformer";
export type Payload = {
type AvataxOrderCreatedPayload = {
order: OrderCreatedSubscriptionFragment;
channelConfig: ChannelConfig;
config: AvataxConfig;
};
export type Target = CreateTransactionArgs;
type Response = CreateOrderResponse;
type AvataxOrderCreatedResponse = CreateOrderResponse;
export class AvataxOrderCreatedAdapter implements WebhookAdapter<Payload, Response> {
export class AvataxOrderCreatedAdapter
implements WebhookAdapter<AvataxOrderCreatedPayload, AvataxOrderCreatedResponse>
{
private logger: Logger;
constructor(private readonly config: AvataxConfig) {
this.logger = createLogger({ service: "AvataxOrderCreatedAdapter" });
this.logger = createLogger({ location: "AvataxOrderCreatedAdapter" });
}
async send(payload: Pick<Payload, "channelConfig" | "order">): Promise<Response> {
this.logger.debug({ payload }, "send called with:");
async send(payload: AvataxOrderCreatedPayload): Promise<AvataxOrderCreatedResponse> {
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
const payloadTransformer = new AvataxOrderCreatedPayloadTransformer();
const target = payloadTransformer.transform({ ...payload, config: this.config });
const payloadTransformer = new AvataxOrderCreatedPayloadTransformer(this.config);
const target = payloadTransformer.transform(payload);
this.logger.debug(
{ transformedPayload: target },
"Will call Avatax createTransaction with transformed payload:"
);
const client = new AvataxClient(this.config);
const response = await client.createTransaction(target);
this.logger.debug({ response }, "Avatax createTransaction response:");
this.logger.debug({ response }, "Avatax createTransaction responded with:");
const responseTransformer = new AvataxOrderCreatedResponseTransformer();
const transformedResponse = responseTransformer.transform(response);

View file

@ -1,40 +1,26 @@
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { ChannelConfig } from "../../channels-configuration/channels-config";
import { ChannelConfig } from "../../channel-configuration/channel-config";
import { orderCreatedTransactionMock } from "./avatax-order-created-response-transaction-mock";
import { AvataxConfig } from "../avatax-config";
import { AvataxConfig } from "../avatax-connection-schema";
import { defaultOrder } from "../../../mocks";
import { AvataxConfigMockGenerator } from "../avatax-config-mock-generator";
const defaultChannelConfig: ChannelConfig = {
providerInstanceId: "aa5293e5-7f5d-4782-a619-222ead918e50",
enabled: false,
address: {
country: "US",
zip: "95008",
state: "CA",
city: "Campbell",
street: "33 N. First Street",
id: "1",
config: {
providerConnectionId: "aa5293e5-7f5d-4782-a619-222ead918e50",
slug: "default-channel",
},
};
const defaultOrderCreatedResponse: TransactionModel = orderCreatedTransactionMock;
const defaultAvataxConfig: AvataxConfig = {
companyCode: "DEFAULT",
isAutocommit: false,
isSandbox: true,
name: "Avatax-1",
password: "password",
username: "username",
shippingTaxCode: "FR000000",
};
const testingScenariosMap = {
default: {
order: defaultOrder,
channelConfig: defaultChannelConfig,
response: defaultOrderCreatedResponse,
avataxConfig: defaultAvataxConfig,
},
};
@ -56,11 +42,11 @@ export class AvataxOrderCreatedMockGenerator {
...overrides,
});
generateAvataxConfig = (overrides: Partial<AvataxConfig> = {}): AvataxConfig =>
structuredClone({
...testingScenariosMap[this.scenario].avataxConfig,
...overrides,
});
generateAvataxConfig = (overrides: Partial<AvataxConfig> = {}): AvataxConfig => {
const mockGenerator = new AvataxConfigMockGenerator();
return mockGenerator.generateAvataxConfig(overrides);
};
generateResponse = (overrides: Partial<TransactionModel> = {}): TransactionModel =>
structuredClone({

View file

@ -22,10 +22,10 @@ const channelConfigMock = mockGenerator.generateChannelConfig();
describe("AvataxOrderCreatedPayloadTransformer", () => {
it("returns lines with discounted: true when there are discounts", () => {
const transformer = new AvataxOrderCreatedPayloadTransformer();
const transformer = new AvataxOrderCreatedPayloadTransformer(avataxConfigMock);
const payloadMock = {
order: discountedOrderMock,
config: avataxConfigMock,
providerConfig: avataxConfigMock,
channelConfig: channelConfigMock,
};
@ -37,10 +37,10 @@ describe("AvataxOrderCreatedPayloadTransformer", () => {
expect(check).toBe(true);
});
it("returns lines with discounted: false when there are no discounts", () => {
const transformer = new AvataxOrderCreatedPayloadTransformer();
const transformer = new AvataxOrderCreatedPayloadTransformer(avataxConfigMock);
const payloadMock = {
order: orderMock,
config: avataxConfigMock,
providerConfig: avataxConfigMock,
channelConfig: channelConfigMock,
};

View file

@ -1,11 +1,11 @@
import { LineItemModel } from "avatax/lib/models/LineItemModel";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { numbers } from "../../taxes/numbers";
import { AvataxConfig } from "../avatax-config";
import { AvataxConfig } from "../avatax-connection-schema";
import { avataxAddressFactory } from "../address-factory";
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { Payload, Target } from "./avatax-order-created-adapter";
import { discountUtils } from "../../taxes/discount-utils";
import { CreateTransactionArgs } from "../avatax-client";
const SHIPPING_ITEM_CODE = "Shipping";
@ -49,24 +49,25 @@ export function mapLines(
}
export class AvataxOrderCreatedPayloadTransformer {
transform = ({ order, channelConfig, config }: Payload): Target => {
constructor(private readonly providerConfig: AvataxConfig) {}
transform = ({ order }: { order: OrderCreatedSubscriptionFragment }): CreateTransactionArgs => {
return {
model: {
type: DocumentType.SalesInvoice,
customerCode:
order.user?.id ??
"" /* In Saleor Avatax plugin, the customer code is 0. In Taxes App, we set it to the user id. */,
companyCode: config.companyCode,
companyCode: this.providerConfig.companyCode,
// * commit: If true, the transaction will be committed immediately after it is created. See: https://developer.avalara.com/communications/dev-guide_rest_v2/commit-uncommit
commit: config.isAutocommit,
commit: this.providerConfig.isAutocommit,
addresses: {
shipFrom: avataxAddressFactory.fromChannelAddress(channelConfig.address),
shipFrom: avataxAddressFactory.fromChannelAddress(this.providerConfig.address),
// billing or shipping address?
shipTo: avataxAddressFactory.fromSaleorAddress(order.billingAddress!),
},
currencyCode: order.total.currency,
email: order.user?.email ?? "",
lines: mapLines(order, config),
lines: mapLines(order, this.providerConfig),
date: new Date(order.created),
discount: discountUtils.sumDiscounts(
order.discounts.map((discount) => discount.amount.amount)

View file

@ -2,34 +2,40 @@ import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphq
import { Logger, createLogger } from "../../../lib/logger";
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { AvataxClient, CommitTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-config";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxOrderFulfilledPayloadTransformer } from "./avatax-order-fulfilled-payload-transformer";
import { AvataxOrderFulfilledResponseTransformer } from "./avatax-order-fulfilled-response-transformer";
export type Payload = {
export type AvataxOrderFulfilledPayload = {
order: OrderFulfilledSubscriptionFragment;
config: AvataxConfig;
};
export type Target = CommitTransactionArgs;
export type Response = { ok: true };
export type AvataxOrderFulfilledTarget = CommitTransactionArgs;
export type AvataxOrderFulfilledResponse = { ok: true };
export class AvataxOrderFulfilledAdapter implements WebhookAdapter<Payload, Response> {
export class AvataxOrderFulfilledAdapter
implements WebhookAdapter<AvataxOrderFulfilledPayload, AvataxOrderFulfilledResponse>
{
private logger: Logger;
constructor(private readonly config: AvataxConfig) {
this.logger = createLogger({ service: "AvataxOrderFulfilledAdapter" });
this.logger = createLogger({ location: "AvataxOrderFulfilledAdapter" });
}
async send(payload: Pick<Payload, "order">): Promise<Response> {
this.logger.debug({ payload }, "send called with:");
async send(payload: AvataxOrderFulfilledPayload): Promise<AvataxOrderFulfilledResponse> {
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
const payloadTransformer = new AvataxOrderFulfilledPayloadTransformer();
const target = payloadTransformer.transform({ ...payload, config: this.config });
const payloadTransformer = new AvataxOrderFulfilledPayloadTransformer(this.config);
const target = payloadTransformer.transform({ ...payload });
this.logger.debug(
{ transformedPayload: target },
"Will call Avatax commitTransaction with transformed payload:"
);
const client = new AvataxClient(this.config);
const response = await client.commitTransaction(target);
this.logger.debug({ response }, "Avatax commitTransaction response:");
this.logger.debug({ response }, "Avatax commitTransaction responded with:");
const responseTransformer = new AvataxOrderFulfilledResponseTransformer();
const transformedResponse = responseTransformer.transform(response);

View file

@ -1,8 +1,7 @@
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { describe, expect, it } from "vitest";
import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql";
import { AvataxConfig } from "../avatax-config";
import { Payload } from "./avatax-order-fulfilled-adapter";
import { AvataxConfig } from "../avatax-connection-schema";
import {
AvataxOrderFulfilledPayloadTransformer,
PROVIDER_ORDER_ID_KEY,
@ -16,9 +15,18 @@ const MOCK_AVATAX_CONFIG: AvataxConfig = {
isAutocommit: false,
isSandbox: true,
name: "Avatax-1",
shippingTaxCode: "FR000000",
address: {
country: "US",
zip: "10118",
state: "NY",
city: "New York",
street: "350 5th Avenue",
},
credentials: {
password: "password",
username: "username",
shippingTaxCode: "FR000000",
},
};
const MOCKED_METADATA: OrderFulfilledSubscriptionFragment["privateMetadata"] = [
@ -121,11 +129,12 @@ describe("getTransactionCodeFromMetadata", () => {
});
});
const transformer = new AvataxOrderFulfilledPayloadTransformer();
const transformer = new AvataxOrderFulfilledPayloadTransformer(MOCK_AVATAX_CONFIG);
const MOCKED_ORDER_FULFILLED_PAYLOAD: Payload = {
const MOCKED_ORDER_FULFILLED_PAYLOAD: {
order: OrderFulfilledSubscriptionFragment;
} = {
order: ORDER_FULFILLED_MOCK,
config: MOCK_AVATAX_CONFIG,
};
describe("AvataxOrderFulfilledPayloadTransformer", () => {

View file

@ -1,6 +1,10 @@
import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql";
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { Payload, Target } from "./avatax-order-fulfilled-adapter";
import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql";
import { AvataxConfig } from "../avatax-connection-schema";
import {
AvataxOrderFulfilledPayload,
AvataxOrderFulfilledTarget,
} from "./avatax-order-fulfilled-adapter";
// * This is the key that we use to store the provider order id in the Saleor order metadata.
@ -19,12 +23,13 @@ export function getTransactionCodeFromMetadata(
}
export class AvataxOrderFulfilledPayloadTransformer {
transform({ order, config }: Payload): Target {
constructor(private readonly config: AvataxConfig) {}
transform({ order }: AvataxOrderFulfilledPayload): AvataxOrderFulfilledTarget {
const transactionCode = getTransactionCodeFromMetadata(order.privateMetadata);
return {
transactionCode,
companyCode: config.companyCode ?? "",
companyCode: this.config.companyCode ?? "",
documentType: DocumentType.SalesInvoice,
model: {
commit: true,

View file

@ -1,8 +1,8 @@
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { Response } from "./avatax-order-fulfilled-adapter";
import { AvataxOrderFulfilledResponse } from "./avatax-order-fulfilled-adapter";
export class AvataxOrderFulfilledResponseTransformer {
transform(response: TransactionModel): Response {
transform(response: TransactionModel): AvataxOrderFulfilledResponse {
return { ok: true };
}
}

View file

@ -1,318 +1,223 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
FormHelperText,
Grid,
InputLabel,
Switch,
TextField,
TextFieldProps,
} from "@material-ui/core";
import { Delete, Save } from "@material-ui/icons";
import { Button, makeStyles } from "@saleor/macaw-ui";
import { TextLink } from "@saleor/apps-ui";
import { Box, Button, Divider, Text } from "@saleor/macaw-ui/next";
import { Input } from "@saleor/react-hook-form-macaw";
import React from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { useInstanceId } from "../../taxes/tax-context";
import { trpcClient } from "../../trpc/trpc-client";
import { AppLink } from "../../ui/app-link";
import { avataxConfigSchema } from "../avatax-config";
import { useDashboardNotification } from "@saleor/apps-shared";
import { useForm } from "react-hook-form";
import { AppCard } from "../../ui/app-card";
import { AppToggle } from "../../ui/app-toggle";
import { CountrySelect } from "../../ui/country-select";
import { ProviderLabel } from "../../ui/provider-label";
import { AvataxConfig, avataxConfigSchema, defaultAvataxConfig } from "../avatax-connection-schema";
const useStyles = makeStyles((theme) => ({
reverseRow: {
display: "flex",
flexDirection: "row-reverse",
gap: theme.spacing(1),
},
}));
const schema = avataxConfigSchema;
type FormValues = z.infer<typeof schema>;
const defaultValues: FormValues = {
companyCode: "",
isAutocommit: false,
isSandbox: false,
password: "",
username: "",
name: "",
shippingTaxCode: "",
};
export const AvataxConfigurationForm = () => {
const { notifySuccess, notifyError } = useDashboardNotification();
const [isWarningDialogOpen, setIsWarningDialogOpen] = React.useState(false);
const styles = useStyles();
const { handleSubmit, reset, control, formState } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues,
});
const { instanceId, setInstanceId } = useInstanceId();
const { refetch: refetchChannelConfigurationData } =
trpcClient.channelsConfiguration.fetch.useQuery(undefined, {
onError(error) {
notifyError("Error", error.message);
},
});
const { refetch: refetchProvidersConfigurationData } =
trpcClient.providersConfiguration.getAll.useQuery();
const { data: instance } = trpcClient.avataxConfiguration.get.useQuery(
{ id: instanceId ?? "" },
{
enabled: !!instanceId,
onError(error) {
notifyError("Error", error.message);
},
}
const HelperText = ({ children }: { children: React.ReactNode }) => {
return (
<Text color="textNeutralSubdued" fontWeight={"captionLarge"}>
{children}
</Text>
);
const resetInstanceId = () => {
setInstanceId(null);
};
type AvataxConfigurationFormProps = {
onSubmit: (data: AvataxConfig) => void;
defaultValues: AvataxConfig;
isLoading: boolean;
cancelButton: React.ReactNode;
};
export const AvataxConfigurationForm = (props: AvataxConfigurationFormProps) => {
const { handleSubmit, control, formState, reset } = useForm({
defaultValues: defaultAvataxConfig,
resolver: zodResolver(avataxConfigSchema),
});
React.useEffect(() => {
if (instance) {
const { config } = instance;
reset(props.defaultValues);
}, [props.defaultValues, reset]);
reset(config);
} else {
reset(defaultValues);
}
}, [instance, reset]);
const { mutate: createMutation, isLoading: isCreateLoading } =
trpcClient.avataxConfiguration.post.useMutation({
onSuccess({ id }) {
setInstanceId(id);
refetchProvidersConfigurationData();
notifySuccess("Success", "Saved app configuration");
const submitHandler = React.useCallback(
(data: AvataxConfig) => {
props.onSubmit(data);
},
onError(error) {
notifyError("Error", error.message);
},
});
const { mutate: updateMutation, isLoading: isUpdateLoading } =
trpcClient.avataxConfiguration.patch.useMutation({
onSuccess() {
refetchProvidersConfigurationData();
notifySuccess("Success", "Updated Avalara configuration");
},
onError(error) {
notifyError("Error", error.message);
},
});
const { mutate: deleteMutation } = trpcClient.avataxConfiguration.delete.useMutation({
onSuccess() {
resetInstanceId();
refetchProvidersConfigurationData();
refetchChannelConfigurationData();
notifySuccess("Success", "Removed Avatax instance");
},
onError(error) {
notifyError("Error", error.message);
},
});
const textFieldProps: TextFieldProps = {
fullWidth: true,
};
const onSubmit = (value: FormValues) => {
if (instanceId) {
updateMutation({
id: instanceId,
value,
});
} else {
createMutation({
value,
});
}
};
const closeWarningDialog = () => {
setIsWarningDialogOpen(false);
};
const openWarningDialog = () => {
setIsWarningDialogOpen(true);
};
const deleteProvider = () => {
closeWarningDialog();
if (instanceId) {
deleteMutation({ id: instanceId });
}
};
const isLoading = isCreateLoading || isUpdateLoading;
[props]
);
return (
<>
<form autoComplete="off" onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Controller
<AppCard>
<Box marginBottom={8}>
<ProviderLabel name="avatax" />
</Box>
<form onSubmit={handleSubmit(submitHandler)}>
<Input
control={control}
name="name"
control={control}
defaultValue={defaultValues.name}
render={({ field }) => (
<TextField
required
type="text"
{...field}
label="Instance name"
{...textFieldProps}
label="Configuration name *"
helperText={formState.errors.name?.message}
/>
)}
/>
{formState.errors.name && (
<FormHelperText error>{formState.errors.name.message}</FormHelperText>
)}
</Grid>
<Grid item xs={12}>
<InputLabel>
Sandbox
<Controller
name={"isSandbox"}
<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}
defaultValue={defaultValues.isSandbox}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
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}
/>
</InputLabel>
<FormHelperText>
<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{" "}
<AppLink
href={
"https://developer.avalara.com/erp-integration-guide/sales-tax-badge/authentication-in-avatax/sandbox-vs-production/"
}
<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>
</AppLink>{" "}
environments.{" "}
</FormHelperText>
</Grid>
<Grid item xs={12}>
<InputLabel>
Autocommit
<Controller
name={"isAutocommit"}
control={control}
defaultValue={defaultValues.isAutocommit}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
</InputLabel>
<FormHelperText>
If enabled, the order will be automatically{" "}
<AppLink
href={
"https://developer.avalara.com/communications/dev-guide_rest_v2/commit-uncommit/"
</TextLink>{" "}
environment.
</HelperText>
}
>
committed to Avalara.
</AppLink>{" "}
</FormHelperText>
</Grid>
<Grid item xs={12}>
<Controller
name="username"
control={control}
defaultValue=""
render={({ field }) => (
<TextField required type="text" {...field} label="Username" {...textFieldProps} />
)}
/>
{formState.errors.username && (
<FormHelperText error>{formState.errors.username.message}</FormHelperText>
)}
</Grid>
<Grid item xs={12}>
<Controller
name="password"
control={control}
defaultValue={defaultValues.password}
render={({ field }) => (
<TextField required label="Password" {...field} {...textFieldProps} />
)}
name="isSandbox"
/>
{formState.errors.password && (
<FormHelperText error>{formState.errors.password.message}</FormHelperText>
)}
</Grid>
<Grid item xs={12}>
<Controller
name="companyCode"
<AppToggle
control={control}
defaultValue={defaultValues.companyCode}
render={({ field }) => (
<TextField type="text" {...field} label="Company code" {...textFieldProps} />
)}
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"
/>
<FormHelperText>
{"When not provided, the default company will be used. "}
<AppLink href="https://developer.avalara.com/erp-integration-guide/sales-tax-badge/transactions/simple-transactions/company-codes/">
Read more
</AppLink>{" "}
about company codes.
</FormHelperText>
{formState.errors.companyCode && (
<FormHelperText error>{formState.errors.companyCode.message}</FormHelperText>
)}
</Grid>
<Grid item xs={12}>
<Controller
</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"
control={control}
defaultValue={defaultValues.shippingTaxCode}
render={({ field }) => (
<TextField type="text" {...field} label="Shipping tax code" {...textFieldProps} />
)}
label="Shipping tax code"
helperText={formState.errors.shippingTaxCode?.message}
/>
<FormHelperText>
{"Tax code that for the shipping line sent to Avatax. "}
<AppLink href="https://taxcode.avatax.avalara.com">
<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.
</AppLink>
</FormHelperText>
{formState.errors.shippingTaxCode && (
<FormHelperText error>{formState.errors.shippingTaxCode.message}</FormHelperText>
)}
</Grid>
</Grid>
<br />
<div className={styles.reverseRow}>
<Button startIcon={<Save />} type="submit" variant="primary">
{isLoading ? "Saving..." : "Save"}
</Button>
{instanceId && (
<Button onClick={deleteProvider} startIcon={<Delete />}>
Delete
</Button>
)}
</TextLink>
</HelperText>
</div>
</Box>
<Divider marginY={8} />
<Box display={"flex"} justifyContent={"space-between"} alignItems={"center"}>
{props.cancelButton}
<Button disabled={props.isLoading} type="submit" variant="primary">
{props.isLoading ? "Saving..." : "Save"}
</Button>
</Box>
</form>
{/* <DeleteProviderDialog
isOpen={isWarningDialogOpen}
onClose={closeWarningDialog}
onCancel={closeWarningDialog}
onConfirm={deleteProvider}
/> */}
</>
</AppCard>
);
};

View file

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

View file

@ -0,0 +1,52 @@
import { TextLink } from "@saleor/apps-ui";
import { Box, Text } from "@saleor/macaw-ui/next";
import { Section } from "../../ui/app-section";
export const AvataxInstructions = () => {
return (
<Section.Description
title="Avatax Configuration"
description={
<>
The form consists of two sections: <i>Credentials</i> and <i>Address</i>.
<br />
<br />
<i>Credentials</i> will fail if:
<Box as="ol" margin={0}>
<li>
<Text>- The username or password are incorrect.</Text>
</li>
<li>
<Text>
- The combination of username and password do not match &quot;sandbox mode&quot;
setting.
</Text>
</li>
</Box>
<br />
<br />
<i>Address</i> will fail if:
<br />
<Box as="ol" margin={0}>
<li>
<Text>
- The address does not match{" "}
<TextLink href="https://developer.avalara.com/avatax/address-validation/" newTab>
the desired format
</TextLink>
.
</Text>
</li>
</Box>
<br />
<br />
If the configuration fails, please visit the{" "}
<TextLink href="https://developer.avalara.com" newTab>
Avatax documentation
</TextLink>
.
</>
}
/>
);
};

View file

@ -0,0 +1,47 @@
import React from "react";
import { AvataxConfigurationForm } from "./avatax-configuration-form";
import { AvataxConfig, defaultAvataxConfig } from "../avatax-connection-schema";
import { trpcClient } from "../../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared";
import { useRouter } from "next/router";
import { Button } from "@saleor/macaw-ui/next";
export const CreateAvataxConfiguration = () => {
const router = useRouter();
const { notifySuccess, notifyError } = useDashboardNotification();
const { refetch: refetchProvidersConfigurationData } =
trpcClient.providersConfiguration.getAll.useQuery();
const { mutate: createMutation, isLoading: isCreateLoading } =
trpcClient.avataxConnection.create.useMutation({
async onSuccess() {
notifySuccess("Success", "Provider created");
await refetchProvidersConfigurationData();
router.push("/configuration");
},
onError(error) {
notifyError("Error", error.message);
},
});
const submitHandler = React.useCallback(
(data: AvataxConfig) => {
createMutation({ value: data });
},
[createMutation]
);
return (
<AvataxConfigurationForm
isLoading={isCreateLoading}
onSubmit={submitHandler}
defaultValues={defaultAvataxConfig}
cancelButton={
<Button onClick={() => router.push("/configuration")} variant="tertiary">
Cancel
</Button>
}
/>
);
};

View file

@ -0,0 +1,107 @@
import { useDashboardNotification } from "@saleor/apps-shared";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { useRouter } from "next/router";
import React from "react";
import { z } from "zod";
import { Obfuscator } from "../../../lib/obfuscator";
import { trpcClient } from "../../trpc/trpc-client";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxConfigurationForm } from "./avatax-configuration-form";
import { AvataxConnectionObfuscator } from "../avatax-connection-obfuscator";
const avataxObfuscator = new AvataxConnectionObfuscator();
export const EditAvataxConfiguration = () => {
const router = useRouter();
const { id } = router.query;
const configurationId = z.string().parse(id ?? "");
const { refetch: refetchProvidersConfigurationData } =
trpcClient.providersConfiguration.getAll.useQuery();
const { notifySuccess, notifyError } = useDashboardNotification();
const { mutate: patchMutation, isLoading: isPatchLoading } =
trpcClient.avataxConnection.update.useMutation({
onSuccess() {
notifySuccess("Success", "Updated Avatax configuration");
refetchProvidersConfigurationData();
},
onError(error) {
notifyError("Error", error.message);
},
});
const { mutate: deleteMutation, isLoading: isDeleteLoading } =
trpcClient.avataxConnection.delete.useMutation({
onSuccess() {
notifySuccess("Success", "Deleted Avatax configuration");
refetchProvidersConfigurationData();
router.push("/configuration");
},
onError(error) {
notifyError("Error", error.message);
},
});
const {
data,
isLoading: isGetLoading,
isError: isGetError,
} = trpcClient.avataxConnection.getById.useQuery(
{
id: configurationId,
},
{
enabled: !!configurationId,
}
);
const submitHandler = React.useCallback(
(data: AvataxConfig) => {
patchMutation({
value: avataxObfuscator.filterOutObfuscated(data),
id: configurationId,
});
},
[configurationId, patchMutation]
);
const deleteHandler = () => {
/*
* // todo: add support for window.confirm to AppBridge or wait on Dialog component in Macaw
* if (window.confirm("Are you sure you want to delete the provider?")) {
*/
deleteMutation({ id: configurationId });
// }
};
if (isGetLoading) {
// todo: replace with skeleton once its available in Macaw
return (
<Box>
<Text color="textNeutralSubdued">Loading...</Text>
</Box>
);
}
if (isGetError) {
return (
<Box>
<Text color="textCriticalDefault">Error while fetching the provider data.</Text>
</Box>
);
}
return (
<AvataxConfigurationForm
isLoading={isPatchLoading}
onSubmit={submitHandler}
defaultValues={data.config}
cancelButton={
<Button onClick={deleteHandler} variant="error">
Delete provider
</Button>
}
/>
);
};

View file

@ -0,0 +1,25 @@
import { ChannelConfig } from "./channel-config";
const defaultChannelConfig: ChannelConfig = {
id: "1",
config: {
providerConnectionId: "aa5293e5-7f5d-4782-a619-222ead918e50",
slug: "default-channel",
},
};
const testingScenariosMap = {
default: defaultChannelConfig,
};
type TestingScenario = keyof typeof testingScenariosMap;
export class ChannelConfigMockGenerator {
constructor(private scenario: TestingScenario = "default") {}
generateChannelConfig = (overrides: Partial<ChannelConfig> = {}): ChannelConfig =>
structuredClone({
...testingScenariosMap[this.scenario],
...overrides,
});
}

View file

@ -0,0 +1,18 @@
import { z } from "zod";
export const channelConfigPropertiesSchema = z.object({
providerConnectionId: z.string().or(z.null()),
slug: z.string(),
});
export type ChannelConfigProperties = z.infer<typeof channelConfigPropertiesSchema>;
export const channelConfigSchema = z.object({
id: z.string(),
config: channelConfigPropertiesSchema,
});
export type ChannelConfig = z.infer<typeof channelConfigSchema>;
export const channelsSchema = z.array(channelConfigSchema);
export type ChannelsConfig = z.infer<typeof channelsSchema>;

View file

@ -0,0 +1,48 @@
import { expect, it, describe } from "vitest";
import { ChannelConfigMockGenerator } from "./channel-config-mock-generator";
import { ChannelFetcherMockGenerator } from "./channel-fetcher-mock-generator";
import { ChannelConfigurationMerger } from "./channel-configuration-merger";
const channelMockGenerator = new ChannelFetcherMockGenerator();
const channelConfigMockGenerator = new ChannelConfigMockGenerator();
const configurationMerger = new ChannelConfigurationMerger();
describe("ChannelConfigurationMerger", () => {
it("should return config with providerConnectionId when match", () => {
const channels = [channelMockGenerator.generateChannel()];
const channelConfig = channelConfigMockGenerator.generateChannelConfig();
const result = configurationMerger.merge(channels, [channelConfig]);
expect(result).toEqual([
{
id: "1",
config: {
providerConnectionId: "aa5293e5-7f5d-4782-a619-222ead918e50",
slug: "default-channel",
},
},
]);
});
it("should return config with providerConnectionId = null when no match", () => {
const channels = [channelMockGenerator.generateChannel()];
const channelConfig = channelConfigMockGenerator.generateChannelConfig({
config: {
providerConnectionId: "1234",
slug: "different-channel",
},
});
const result = configurationMerger.merge(channels, [channelConfig]);
expect(result).toEqual([
{
id: "1",
config: {
providerConnectionId: null,
slug: "default-channel",
},
},
]);
});
});

View file

@ -0,0 +1,18 @@
import { ChannelFragment } from "../../../generated/graphql";
import { ChannelsConfig } from "./channel-config";
export class ChannelConfigurationMerger {
merge(channels: ChannelFragment[], channelsConfig: ChannelsConfig): ChannelsConfig {
return channels.map((channel) => {
const channelConfig = channelsConfig.find((c) => c.config.slug === channel.slug);
return {
id: channel.id,
config: {
providerConnectionId: channelConfig?.config.providerConnectionId ?? null,
slug: channel.slug,
},
};
});
}
}

View file

@ -0,0 +1,29 @@
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
import { Logger, createLogger } from "../../lib/logger";
import { CrudSettingsManager } from "../crud-settings/crud-settings.service";
import { ChannelConfigProperties, channelsSchema } from "./channel-config";
export class ChannelConfigurationSettings {
private crudSettingsManager: CrudSettingsManager;
private logger: Logger;
constructor(private settingsManager: EncryptedMetadataManager, saleorApiUrl: string) {
this.crudSettingsManager = new CrudSettingsManager(
settingsManager,
saleorApiUrl,
"channel-configuration"
);
this.logger = createLogger({
location: "ChannelConfigurationSettings",
});
}
async getAll() {
const { data } = await this.crudSettingsManager.readAll();
return channelsSchema.parse(data);
}
async upsert(id: string, data: ChannelConfigProperties) {
await this.crudSettingsManager.upsert(id, { config: data });
}
}

View file

@ -0,0 +1,46 @@
import { createLogger } from "../../lib/logger";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server";
import { channelConfigSchema } from "./channel-config";
import { ChannelConfigurationService } from "./channel-configuration.service";
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
next({
ctx: {
...ctx,
connectionService: new ChannelConfigurationService(
ctx.apiClient,
ctx.appId!,
ctx.saleorApiUrl
),
},
})
);
export const channelsConfigurationRouter = router({
fetch: protectedWithConfigurationService.query(async ({ ctx, input }) => {
const logger = createLogger({
location: "channelsConfigurationRouter.fetch",
});
const channelConfiguration = ctx.connectionService;
logger.info("Returning channel configuration");
return channelConfiguration.getAll();
}),
upsert: protectedWithConfigurationService
.input(channelConfigSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "channelsConfigurationRouter.upsert",
});
const configurationService = ctx.connectionService;
await configurationService.update(input.id, input.config);
logger.info("Channel configuration updated");
}),
});

View file

@ -0,0 +1,55 @@
import { Client } from "urql";
import { ChannelConfigProperties } from "./channel-config";
import { ChannelConfigurationSettings } from "./channel-configuration-settings";
import { ChannelsFetcher } from "./channel-fetcher";
import { ChannelConfigurationMerger } from "./channel-configuration-merger";
import { TaxChannelsV1toV2MigrationManager } from "../../../scripts/migrations/tax-channels-migration-v1-to-v2";
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
import { Logger, createLogger } from "../../lib/logger";
import { createSettingsManager } from "../app/metadata-manager";
export class ChannelConfigurationService {
private configurationService: ChannelConfigurationSettings;
private logger: Logger;
private settingsManager: EncryptedMetadataManager;
constructor(private client: Client, private appId: string, private saleorApiUrl: string) {
const settingsManager = createSettingsManager(client, appId);
this.settingsManager = settingsManager;
this.logger = createLogger({
location: "ChannelConfigurationService",
});
this.configurationService = new ChannelConfigurationSettings(settingsManager, saleorApiUrl);
}
async getAll() {
const channelsFetcher = new ChannelsFetcher(this.client);
const migrationManager = new TaxChannelsV1toV2MigrationManager(
this.settingsManager,
this.saleorApiUrl
);
const migratedConfig = await migrationManager.migrateIfNeeded();
if (migratedConfig) {
this.logger.info("Config migrated", migratedConfig);
return migratedConfig;
}
this.logger.info("Config is up to date, no need to migrate.");
const channels = await channelsFetcher.fetchChannels();
const channelConfiguration = await this.configurationService.getAll();
const configurationMerger = new ChannelConfigurationMerger();
return configurationMerger.merge(channels, channelConfiguration);
}
async update(id: string, data: ChannelConfigProperties) {
await this.configurationService.upsert(id, data);
}
}

View file

@ -0,0 +1,23 @@
import { ChannelFragment } from "../../../generated/graphql";
const defaultChannel: ChannelFragment = {
id: "1",
name: "Default Channel",
slug: "default-channel",
};
const testingScenariosMap = {
default: defaultChannel,
};
type TestingScenario = keyof typeof testingScenariosMap;
export class ChannelFetcherMockGenerator {
constructor(private scenario: TestingScenario = "default") {}
generateChannel = (overrides: Partial<ChannelFragment> = {}): ChannelFragment =>
structuredClone({
...testingScenariosMap[this.scenario],
...overrides,
});
}

View file

@ -0,0 +1,61 @@
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { trpcClient } from "../../trpc/trpc-client";
import { AppCard } from "../../ui/app-card";
import { ChannelTable } from "./channel-table";
const NoChannelConfigured = () => {
const appBridge = useAppBridge();
const redirectToTaxes = () => {
appBridge.appBridge?.dispatch(actions.Redirect({ to: "/taxes/channels" }));
};
return (
<Box
display={"flex"}
flexDirection={"column"}
gap={6}
alignItems={"center"}
height={"100%"}
justifyContent={"center"}
>
<Text variant="body">No channels configured yet</Text>
<Button onClick={redirectToTaxes}>Configure channels</Button>
</Box>
);
};
const Skeleton = () => {
// todo: replace with skeleton
return (
<Box height={"100%"} display={"flex"} alignItems={"center"} justifyContent={"center"}>
<Text color="textNeutralSubdued">Loading...</Text>
</Box>
);
};
export const ChannelList = () => {
const { data = [], isFetching, isFetched } = trpcClient.channelsConfiguration.fetch.useQuery();
const isAnyChannelConfigured = data.length > 0;
const isResult = isFetched && isAnyChannelConfigured;
const isEmpty = isFetched && !isAnyChannelConfigured;
return (
<AppCard __minHeight={"320px"} height="100%">
{isFetching ? (
<Skeleton />
) : (
<>
{isEmpty && <NoChannelConfigured />}
{isResult && (
<Box height="100%">
<ChannelTable />
</Box>
)}
</>
)}
</AppCard>
);
};

View file

@ -0,0 +1,49 @@
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Text } from "@saleor/macaw-ui/next";
import { Section } from "../../ui/app-section";
import { ChannelList } from "./channel-list";
const Intro = () => {
const appBridge = useAppBridge();
const redirectToTaxes = () => {
appBridge.appBridge?.dispatch(actions.Redirect({ to: "/taxes/channels" }));
};
return (
<Section.Description
title="Available channels"
description={
<>
This table displays all the channels configured to use the tax app as the tax calculation
method.
<br />
<br />
You can change the tax configuration method for each channel in the{" "}
<Text
transition={"ease"}
variant={"bodyStrong"}
cursor={"pointer"}
color={{
default: "text3Decorative",
hover: "text1Decorative",
}}
onClick={redirectToTaxes}
>
Configuration Taxes
</Text>{" "}
view.
</>
}
/>
);
};
export const ChannelSection = () => {
return (
<>
<Intro />
<ChannelList />
</>
);
};

View file

@ -0,0 +1,76 @@
import { Select } from "@saleor/macaw-ui/next";
import React from "react";
import { trpcClient } from "../../trpc/trpc-client";
import { Table } from "../../ui/table";
import { ChannelConfig } from "../channel-config";
import { useDashboardNotification } from "@saleor/apps-shared";
const SelectProvider = (channelConfig: ChannelConfig) => {
const {
config: { providerConnectionId = "", slug },
id,
} = channelConfig;
const [value, setValue] = React.useState(providerConnectionId);
const { notifySuccess, notifyError } = useDashboardNotification();
const { mutate: upsertMutation } = trpcClient.channelsConfiguration.upsert.useMutation({
onSuccess() {
notifySuccess("Success", "Updated channel configuration");
},
onError(error) {
notifyError("Error", error.message);
},
});
const { data: providerConfigurations = [] } = trpcClient.providersConfiguration.getAll.useQuery();
const changeValue = (nextproviderConnectionId: string) => {
setValue(nextproviderConnectionId);
upsertMutation({
id,
config: {
providerConnectionId: nextproviderConnectionId,
slug,
},
});
};
return (
<Select
value={value ?? ""}
onChange={(value) => changeValue(String(value))}
options={[
{ value: "", label: "Not assigned" },
...providerConfigurations.map((item) => ({
value: item.id,
label: item.config.name,
})),
]}
/>
);
};
export const ChannelTable = () => {
const { data = [] } = trpcClient.channelsConfiguration.fetch.useQuery();
return (
<Table.Container>
<Table.THead color={"textNeutralSubdued"}>
<Table.TR>
<Table.TH>Channel slug</Table.TH>
<Table.TH>Provider</Table.TH>
</Table.TR>
</Table.THead>
<Table.TBody>
{data.map((item) => (
<Table.TR key={item.id}>
<Table.TD>{item.config.slug}</Table.TD>
<Table.TD>
<SelectProvider {...item} />
</Table.TD>
</Table.TR>
))}
</Table.TBody>
</Table.Container>
);
};

View file

@ -1,7 +0,0 @@
import { z } from "zod";
import { channelSchema } from "./channels-config";
export const setAndReplaceChannelsInputSchema = z.object({
channelSlug: z.string(),
config: channelSchema,
});

View file

@ -1,46 +0,0 @@
import { z } from "zod";
import { ChannelFragment } from "../../../generated/graphql";
const addressSchema = z.object({
country: z.string(),
zip: z.string(),
state: z.string(),
city: z.string(),
street: z.string(),
});
export type ChannelAddress = z.infer<typeof addressSchema>;
export const channelSchema = z.object({
providerInstanceId: z.string(),
enabled: z.boolean(),
address: addressSchema,
});
export type ChannelConfig = z.infer<typeof channelSchema>;
export const channelsSchema = z.record(channelSchema);
export type ChannelsConfig = z.infer<typeof channelsSchema>;
export const defaultChannelConfig: ChannelConfig = {
providerInstanceId: "",
address: {
city: "",
country: "",
state: "",
street: "",
zip: "",
},
enabled: false,
};
export const createDefaultChannelsConfig = (channels: ChannelFragment[]): ChannelsConfig => {
return channels.reduce(
(prev, next) => ({
...prev,
[next.slug]: {
...defaultChannelConfig,
},
}),
{} as ChannelsConfig
);
};

View file

@ -1,61 +0,0 @@
import { createLogger } from "../../lib/logger";
import { createSettingsManager } from "../app/metadata-manager";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server";
import { ChannelsConfig } from "./channels-config";
import { setAndReplaceChannelsInputSchema } from "./channels-config-input-schema";
import { TaxChannelsConfigurator } from "./channels-configurator";
import { GetChannelsConfigurationService } from "./get-channels-configuration.service";
// todo: refactor with crud-settings
export const channelsConfigurationRouter = router({
fetch: protectedClientProcedure.query(async ({ ctx, input }) => {
const logger = createLogger({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "channelsConfigurationRouter.fetch",
});
logger.debug("channelsConfigurationRouter.fetch called");
return new GetChannelsConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
}),
upsert: protectedClientProcedure
.input(setAndReplaceChannelsInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "channelsConfigurationRouter.upsert",
});
logger.debug(input, "channelsConfigurationRouter.upsert called with input");
const config = await new GetChannelsConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
logger.debug(config, "Fetched current channels config to update it");
const taxChannelsConfigurator = new TaxChannelsConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const channelsConfig: ChannelsConfig = {
...config,
[input.channelSlug]: {
...config?.[input.channelSlug],
...input.config,
},
};
logger.debug(channelsConfig, "Merged configs. Will set it now");
await taxChannelsConfigurator.setConfig(channelsConfig);
return null;
}),
});

View file

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

View file

@ -1,33 +0,0 @@
import { Client } from "urql";
import { createLogger } from "../../lib/logger";
import { createSettingsManager } from "../app/metadata-manager";
import { TaxChannelsConfigurator } from "./channels-configurator";
export class GetChannelsConfigurationService {
constructor(
private settings: {
apiClient: Client;
saleorApiUrl: string;
}
) {}
async getConfiguration() {
const logger = createLogger({
service: "GetChannelsConfigurationService",
saleorApiUrl: this.settings.saleorApiUrl,
});
const { saleorApiUrl, apiClient } = this.settings;
const taxConfigurator = new TaxChannelsConfigurator(
createSettingsManager(apiClient),
saleorApiUrl
);
const appChannelsConfig = (await taxConfigurator.getConfig()) ?? null;
logger.debug(appChannelsConfig, "Retrieved channels config from Metadata");
return { ...appChannelsConfig };
}
}

View file

@ -1,17 +0,0 @@
import { createClient } from "../../lib/graphql";
import { router } from "../trpc/trpc-server";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { ChannelsFetcher } from "./channels-fetcher";
import { ChannelFragment } from "../../../generated/graphql";
export const channelsRouter = router({
fetch: protectedClientProcedure.query(async ({ ctx, input }): Promise<ChannelFragment[]> => {
const client = createClient(ctx.saleorApiUrl, async () =>
Promise.resolve({ token: ctx.appToken })
);
const fetcher = new ChannelsFetcher(client);
return fetcher.fetchChannels().then((channels) => channels ?? []);
}),
});

View file

@ -1,218 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
FormGroup,
FormHelperText,
Grid,
InputLabel,
MenuItem,
Select,
TextField,
TextFieldProps,
Typography,
} from "@material-ui/core";
import { Save } from "@material-ui/icons";
import { Button, makeStyles } from "@saleor/macaw-ui";
import { useDashboardNotification } from "@saleor/apps-shared";
import React from "react";
import { Controller, useForm } from "react-hook-form";
import {
ChannelConfig,
channelSchema,
defaultChannelConfig,
} from "../../channels-configuration/channels-config";
import { ProvidersConfig } from "../../providers-configuration/providers-config";
import { ProviderIcon } from "../../providers-configuration/ui/provider-icon";
import { useChannelSlug } from "../../taxes/tax-context";
import { trpcClient } from "../../trpc/trpc-client";
import { CountrySelect } from "../../ui/country-select/country-select";
type ChannelTaxProviderFormValues = ChannelConfig;
const useStyles = makeStyles((theme) => ({
reverseRow: {
display: "flex",
flexDirection: "row-reverse",
},
menuItem: {
display: "flex",
gap: theme.spacing(1),
alignItems: "center",
},
helperText: {
marginTop: 0,
marginBottom: theme.spacing(1),
},
}));
const getDefaultFormValues = (
channel: ChannelConfig | undefined,
providers: ProvidersConfig
): ChannelTaxProviderFormValues => {
if (channel && channel.providerInstanceId !== "") {
return {
...defaultChannelConfig,
...channel,
};
}
const isOnlyOneInstance = providers.length === 1;
if (isOnlyOneInstance) {
return {
...defaultChannelConfig,
providerInstanceId: providers[0].id,
};
}
return defaultChannelConfig;
};
export const ChannelTaxProviderForm = () => {
const styles = useStyles();
const { control, reset, handleSubmit } = useForm<ChannelTaxProviderFormValues>({
resolver: zodResolver(channelSchema),
});
const { notifyError, notifySuccess } = useDashboardNotification();
const { channelSlug } = useChannelSlug();
const { data: channelConfigurationData, refetch: refetchChannelConfigurationData } =
trpcClient.channelsConfiguration.fetch.useQuery(undefined, {
onError(error) {
notifyError("Error", error.message);
},
});
const { data: providerInstances = [] } = trpcClient.providersConfiguration.getAll.useQuery(
undefined,
{
onError(error) {
notifyError("Error", error.message);
},
}
);
const channelConfig = channelConfigurationData?.[channelSlug];
const { mutate, isLoading } = trpcClient.channelsConfiguration.upsert.useMutation({
onSuccess() {
refetchChannelConfigurationData();
notifySuccess("Success", `Saved configuration of channel: ${channelSlug}`);
},
onError(error) {
notifyError("Error", error.message);
},
});
React.useEffect(() => {
const defaultValues = getDefaultFormValues(channelConfig, providerInstances);
reset(defaultValues);
}, [channelConfig, providerInstances, reset]);
const textFieldProps: TextFieldProps = {
fullWidth: true,
};
const onSubmit = (values: ChannelTaxProviderFormValues) => {
mutate({
channelSlug,
config: {
...values,
},
});
};
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={4}>
<Grid item xs={12}>
<InputLabel>
Channel tax provider
<Controller
name={"providerInstanceId"}
control={control}
defaultValue={""}
render={({ field }) => (
<Select fullWidth {...field}>
{providerInstances.map(({ config, id, provider }) => (
<MenuItem value={id} key={id}>
<div className={styles.menuItem}>
<Typography variant="body1">{config.name}</Typography>
<ProviderIcon size={"medium"} provider={provider} />
</div>
</MenuItem>
))}
</Select>
)}
/>
</InputLabel>
</Grid>
<Grid item xs={12}>
<FormGroup>
<Typography variant="h4">Ship from address</Typography>
<FormHelperText className={styles.helperText}>
The taxes will be calculated based on the address.
</FormHelperText>
<Grid container spacing={2}>
<Grid item xs={8}>
<Controller
name="address.country"
control={control}
render={({ field }) => <CountrySelect {...field} />}
/>
</Grid>
<Grid item xs={4}>
<Controller
name="address.zip"
control={control}
defaultValue=""
render={({ field }) => <TextField {...field} label="Zip" {...textFieldProps} />}
/>
</Grid>
<Grid item xs={6}>
<Controller
name="address.state"
control={control}
defaultValue=""
render={({ field }) => (
<TextField {...field} label="State" {...textFieldProps} />
)}
/>
</Grid>
<Grid item xs={6}>
<Controller
name="address.city"
control={control}
defaultValue=""
render={({ field }) => (
<TextField {...field} label="City" {...textFieldProps} />
)}
/>
</Grid>
<Grid item xs={12}>
<Controller
name="address.street"
control={control}
defaultValue=""
render={({ field }) => (
<TextField {...field} label="Street" {...textFieldProps} />
)}
/>
</Grid>
</Grid>
</FormGroup>
</Grid>
</Grid>
<br />
<div className={styles.reverseRow}>
<Button variant="primary" startIcon={<Save />} type="submit">
{isLoading ? "Saving..." : "Save"}
</Button>{" "}
</div>
</form>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show more