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:
parent
cb6ee2920b
commit
f96563fc2b
197 changed files with 5348 additions and 3635 deletions
5
.changeset/blue-lies-enjoy.md
Normal file
5
.changeset/blue-lies-enjoy.md
Normal 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.
|
5
.changeset/eight-toes-kneel.md
Normal file
5
.changeset/eight-toes-kneel.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"@saleor/react-hook-form-macaw": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added a binding for the macaw-ui's Toggle component.
|
5
.changeset/few-boxes-doubt.md
Normal file
5
.changeset/few-boxes-doubt.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"@saleor/apps-ui": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixed a missing text-decoration on a breadcrumb link.
|
|
@ -1,31 +1,52 @@
|
||||||
/*
|
/** @type {import('next').NextConfig} */
|
||||||
* 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}
|
|
||||||
*/
|
|
||||||
const config = {
|
const config = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
experimental: {
|
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"],
|
||||||
esmExternals: true,
|
|
||||||
},
|
|
||||||
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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(
|
module.exports = withSentryConfig(
|
||||||
config,
|
config,
|
||||||
{ silent: true },
|
|
||||||
{
|
{
|
||||||
hideSourcemaps: true,
|
/*
|
||||||
disableServerWebpackPlugin: !isSentryPropertiesInEnvironment,
|
* For all available options, see:
|
||||||
disableClientWebpackPlugin: !isSentryPropertiesInEnvironment,
|
* 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,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -13,13 +13,11 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^2.9.10",
|
"@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/app-sdk": "0.40.1",
|
||||||
"@saleor/apps-shared": "workspace:*",
|
"@saleor/apps-shared": "workspace:*",
|
||||||
"@saleor/apps-ui": "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",
|
"@sentry/nextjs": "^7.45.0",
|
||||||
"@tanstack/react-query": "^4.19.1",
|
"@tanstack/react-query": "^4.19.1",
|
||||||
"@trpc/client": "^10.9.0",
|
"@trpc/client": "^10.9.0",
|
||||||
|
@ -30,7 +28,8 @@
|
||||||
"@urql/exchange-multipart-fetch": "^1.0.1",
|
"@urql/exchange-multipart-fetch": "^1.0.1",
|
||||||
"avatax": "^23.3.2",
|
"avatax": "^23.3.2",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"graphql": "16.6.0",
|
"dotenv": "^16.0.3",
|
||||||
|
"graphql": "^16.6.0",
|
||||||
"graphql-tag": "^2.12.6",
|
"graphql-tag": "^2.12.6",
|
||||||
"jotai": "^2.0.0",
|
"jotai": "^2.0.0",
|
||||||
"jsdom": "^20.0.3",
|
"jsdom": "^20.0.3",
|
||||||
|
@ -39,7 +38,7 @@
|
||||||
"pino-pretty": "^10.0.0",
|
"pino-pretty": "^10.0.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.42.1",
|
"react-hook-form": "^7.43.9",
|
||||||
"taxjar": "^4.0.1",
|
"taxjar": "^4.0.1",
|
||||||
"urql": "^3.0.3",
|
"urql": "^3.0.3",
|
||||||
"usehooks-ts": "^2.9.1",
|
"usehooks-ts": "^2.9.1",
|
||||||
|
|
21
apps/taxes/scripts/migrations/channels-config-schema-v1.ts
Normal file
21
apps/taxes/scripts/migrations/channels-config-schema-v1.ts
Normal 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>;
|
|
@ -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>;
|
30
apps/taxes/scripts/migrations/migration-utils.ts
Normal file
30
apps/taxes/scripts/migrations/migration-utils.ts
Normal 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();
|
||||||
|
};
|
122
apps/taxes/scripts/migrations/run-generate-dummy-data.ts
Normal file
122
apps/taxes/scripts/migrations/run-generate-dummy-data.ts
Normal 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();
|
78
apps/taxes/scripts/migrations/run-migration.ts
Normal file
78
apps/taxes/scripts/migrations/run-migration.ts
Normal 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();
|
78
apps/taxes/scripts/migrations/run-report.ts
Normal file
78
apps/taxes/scripts/migrations/run-report.ts
Normal 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();
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>;
|
|
@ -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>;
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 `);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -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
|
|
||||||
});
|
|
34
apps/taxes/sentry.client.config.ts
Normal file
34
apps/taxes/sentry.client.config.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
|
@ -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
|
|
||||||
});
|
|
18
apps/taxes/sentry.edge.config.ts
Normal file
18
apps/taxes/sentry.edge.config.ts
Normal 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,
|
||||||
|
});
|
|
@ -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
|
|
||||||
});
|
|
17
apps/taxes/sentry.server.config.ts
Normal file
17
apps/taxes/sentry.server.config.ts
Normal 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,
|
||||||
|
});
|
|
@ -1,2 +1,3 @@
|
||||||
export { default as AvataxIcon } from "./avatax-icon.svg";
|
export { default as AvataxIcon } from "./avatax-icon.svg";
|
||||||
export { default as TaxJarIcon } from "./taxjar-icon.svg";
|
export { default as TaxJarIcon } from "./taxjar-icon.svg";
|
||||||
|
export { default as StripeTaxIcon } from "./stripe-icon.svg";
|
||||||
|
|
9
apps/taxes/src/assets/stripe-icon.svg
Normal file
9
apps/taxes/src/assets/stripe-icon.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 234 KiB |
|
@ -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,
|
|
||||||
});
|
|
22
apps/taxes/src/lib/obfuscator.test.ts
Normal file
22
apps/taxes/src/lib/obfuscator.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
16
apps/taxes/src/lib/obfuscator.ts
Normal file
16
apps/taxes/src/lib/obfuscator.ts
Normal 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("****");
|
||||||
|
}
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,28 +1,25 @@
|
||||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
import { useTheme } from "@saleor/macaw-ui";
|
import { useTheme } from "@saleor/macaw-ui/next";
|
||||||
import { memo, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
/**
|
// todo move to shared
|
||||||
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
|
export function ThemeSynchronizer() {
|
||||||
* Macaw must be informed about this change from AppBridge.
|
|
||||||
*
|
|
||||||
* If you are not using Macaw, you can remove this.
|
|
||||||
*/
|
|
||||||
function _ThemeSynchronizer() {
|
|
||||||
const { appBridgeState } = useAppBridge();
|
const { appBridgeState } = useAppBridge();
|
||||||
const { setTheme, themeType } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!setTheme || !appBridgeState?.theme) {
|
if (!setTheme || !appBridgeState?.theme) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (themeType !== appBridgeState?.theme) {
|
if (appBridgeState.theme === "light") {
|
||||||
setTheme(appBridgeState.theme);
|
setTheme("defaultLight");
|
||||||
}
|
}
|
||||||
}, [appBridgeState?.theme, setTheme, themeType]);
|
|
||||||
|
if (appBridgeState.theme === "dark") {
|
||||||
|
setTheme("defaultDark");
|
||||||
|
}
|
||||||
|
}, [appBridgeState?.theme, setTheme]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemeSynchronizer = memo(_ThemeSynchronizer);
|
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
const { randomUUID } = require("crypto"); // Added in: node v14.17.0
|
const { randomUUID } = require("crypto"); // Added in: node v14.17.0
|
||||||
|
|
||||||
export const createId = (): string => randomUUID();
|
export const createId = (): string => randomUUID();
|
||||||
|
|
||||||
export const obfuscateSecret = (value: string) => value.replace(/.(?=.{4})/g, "*");
|
|
||||||
|
|
||||||
export const isObfuscated = (value: string) => value.includes("****");
|
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { encrypt } from "@saleor/app-sdk/settings-manager";
|
import { encrypt } from "@saleor/app-sdk/settings-manager";
|
||||||
import { getAppConfig } from "./get-app-config";
|
import { getAppConfig } from "./get-app-config";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
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 { MetadataItem } from "../../../generated/graphql";
|
||||||
import { ChannelsConfig } from "../channels-configuration/channels-config";
|
import { ChannelsConfig } from "../channel-configuration/channel-config";
|
||||||
|
|
||||||
const mockedSecretKey = "test_secret_key";
|
const mockedSecretKey = "test_secret_key";
|
||||||
const mockedProviders: ProvidersConfig = [
|
const mockedProviders: ProviderConnections = [
|
||||||
{
|
{
|
||||||
provider: "avatax",
|
provider: "avatax",
|
||||||
id: "1",
|
id: "1",
|
||||||
|
@ -15,25 +15,11 @@ const mockedProviders: ProvidersConfig = [
|
||||||
isAutocommit: false,
|
isAutocommit: false,
|
||||||
isSandbox: true,
|
isSandbox: true,
|
||||||
name: "avatax-1",
|
name: "avatax-1",
|
||||||
|
shippingTaxCode: "FR000000",
|
||||||
|
credentials: {
|
||||||
password: "avatax-password",
|
password: "avatax-password",
|
||||||
username: "avatax-username",
|
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: {
|
address: {
|
||||||
city: "New York",
|
city: "New York",
|
||||||
country: "US",
|
country: "US",
|
||||||
|
@ -41,10 +27,38 @@ const mockedChannels: ChannelsConfig = {
|
||||||
street: "123 Main St",
|
street: "123 Main St",
|
||||||
zip: "10001",
|
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);
|
const mockedEncryptedChannels = encrypt(JSON.stringify(mockedChannels), mockedSecretKey);
|
||||||
|
|
||||||
|
@ -62,17 +76,17 @@ const mockedMetadata: MetadataItem[] = [
|
||||||
vi.stubEnv("SECRET_KEY", mockedSecretKey);
|
vi.stubEnv("SECRET_KEY", mockedSecretKey);
|
||||||
|
|
||||||
describe("getAppConfig", () => {
|
describe("getAppConfig", () => {
|
||||||
it("returns empty providers and channels config when no metadata", () => {
|
it("returns empty providerConnections and channels config when no metadata", () => {
|
||||||
const { providers, channels } = getAppConfig([]);
|
const { providerConnections, channels } = getAppConfig([]);
|
||||||
|
|
||||||
expect(providers).toEqual([]);
|
expect(providerConnections).toEqual([]);
|
||||||
expect(channels).toEqual({});
|
expect(channels).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns decrypted providers and channels config when metadata provided", () => {
|
it("returns decrypted providerConnections and channels config when metadata provided", () => {
|
||||||
const { providers, channels } = getAppConfig(mockedMetadata);
|
const { providerConnections, channels } = getAppConfig(mockedMetadata);
|
||||||
|
|
||||||
expect(providers).toEqual(mockedProviders);
|
expect(providerConnections).toEqual(mockedProviders);
|
||||||
expect(channels).toEqual(mockedChannels);
|
expect(channels).toEqual(mockedChannels);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
import { decrypt } from "@saleor/app-sdk/settings-manager";
|
import { decrypt } from "@saleor/app-sdk/settings-manager";
|
||||||
import { MetadataItem } from "../../../generated/graphql";
|
import { MetadataItem } from "../../../generated/graphql";
|
||||||
import { ChannelsConfig, channelsSchema } from "../channels-configuration/channels-config";
|
import { ChannelsConfig, channelsSchema } from "../channel-configuration/channel-config";
|
||||||
import { ProvidersConfig, providersSchema } from "../providers-configuration/providers-config";
|
import {
|
||||||
|
ProviderConnections,
|
||||||
|
providerConnectionsSchema,
|
||||||
|
} from "../provider-connections/provider-connections";
|
||||||
|
|
||||||
export const getAppConfig = (metadata: MetadataItem[]) => {
|
export const getAppConfig = (metadata: MetadataItem[]) => {
|
||||||
let providersConfig = [] as ProvidersConfig;
|
let providerConnections = [] as ProviderConnections;
|
||||||
let channelsConfig = {} as ChannelsConfig;
|
let channelsConfig = {} as ChannelsConfig;
|
||||||
|
|
||||||
const secretKey = process.env.SECRET_KEY;
|
const secretKey = process.env.SECRET_KEY;
|
||||||
|
@ -21,10 +24,10 @@ export const getAppConfig = (metadata: MetadataItem[]) => {
|
||||||
const decrypted = decrypt(item.value, secretKey);
|
const decrypted = decrypt(item.value, secretKey);
|
||||||
const parsed = JSON.parse(decrypted);
|
const parsed = JSON.parse(decrypted);
|
||||||
|
|
||||||
const providersValidation = providersSchema.safeParse(parsed);
|
const providerConnectionValidation = providerConnectionsSchema.safeParse(parsed);
|
||||||
|
|
||||||
if (providersValidation.success) {
|
if (providerConnectionValidation.success) {
|
||||||
providersConfig = providersValidation.data;
|
providerConnections = providerConnectionValidation.data;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,5 +39,5 @@ export const getAppConfig = (metadata: MetadataItem[]) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { providers: providersConfig, channels: channelsConfig };
|
return { providerConnections: providerConnections, channels: channelsConfig };
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,24 +1,41 @@
|
||||||
import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
|
import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
|
||||||
import { Client } from "urql";
|
import { Client, gql } from "urql";
|
||||||
import {
|
import {
|
||||||
FetchAppDetailsDocument,
|
FetchAppDetailsDocument,
|
||||||
FetchAppDetailsQuery,
|
FetchAppDetailsQuery,
|
||||||
UpdateMetadataDocument,
|
UpdateMetadataDocument,
|
||||||
} from "../../../generated/graphql";
|
} 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[]> {
|
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
|
||||||
const logger = createLogger({ service: "fetchAllMetadata" });
|
|
||||||
|
|
||||||
logger.debug("Fetching metadata from Saleor");
|
|
||||||
|
|
||||||
const { error, data } = await client
|
const { error, data } = await client
|
||||||
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
|
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
|
||||||
.toPromise();
|
.toPromise();
|
||||||
|
|
||||||
// * `metadata` name is required for secrets censorship
|
|
||||||
logger.debug({ error, metadata: data }, "Metadata fetched");
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return [];
|
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 })) || [];
|
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
|
export async function mutateMetadata(client: Client, metadata: MetadataEntry[], appId: string) {
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: mutationError, data: mutationData } = await client
|
const { error: mutationError, data: mutationData } = await client
|
||||||
.mutation(UpdateMetadataDocument, {
|
.mutation(UpdateMetadataDocument, {
|
||||||
id: appId,
|
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.
|
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
|
||||||
* We recommend it for production, because all values are encrypted.
|
* We recommend it for production, because all values are encrypted.
|
||||||
* If your use case require plain text values, you can use MetadataManager.
|
* 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
|
// Secret key should be randomly created for production and set as environment variable
|
||||||
encryptionKey: process.env.SECRET_KEY!,
|
encryptionKey: process.env.SECRET_KEY!,
|
||||||
fetchMetadata: () => fetchAllMetadata(client),
|
fetchMetadata: () => fetchAllMetadata(client),
|
||||||
mutateMetadata: (metadata) => mutateMetadata(client, metadata),
|
mutateMetadata: (metadata) => mutateMetadata(client, metadata, appId),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -9,12 +9,12 @@ export class WebhookResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
private returnSuccess(data?: unknown) {
|
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 ?? {});
|
return this.res.status(200).json(data ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
private returnError(errorMessage: string) {
|
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 });
|
return this.res.status(500).json({ error: errorMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo";
|
import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo";
|
||||||
import { ChannelAddress } from "../channels-configuration/channels-config";
|
import { AvataxConfig } from "./avatax-connection-schema";
|
||||||
import { AddressFragment as SaleorAddress } from "../../../generated/graphql";
|
import { AddressFragment } from "../../../generated/graphql";
|
||||||
|
|
||||||
function mapSaleorAddressToAvataxAddress(address: SaleorAddress): AvataxAddress {
|
function mapSaleorAddressToAvataxAddress(address: AddressFragment): AvataxAddress {
|
||||||
return {
|
return {
|
||||||
line1: address.streetAddress1,
|
line1: address.streetAddress1,
|
||||||
line2: address.streetAddress2,
|
line2: address.streetAddress2,
|
||||||
|
@ -13,7 +13,7 @@ function mapSaleorAddressToAvataxAddress(address: SaleorAddress): AvataxAddress
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapChannelAddressToAvataxAddress(address: ChannelAddress): AvataxAddress {
|
function mapChannelAddressToAvataxAddress(address: AvataxConfig["address"]): AvataxAddress {
|
||||||
return {
|
return {
|
||||||
line1: address.street,
|
line1: address.street,
|
||||||
city: address.city,
|
city: address.city,
|
||||||
|
|
|
@ -2,7 +2,7 @@ import Avatax from "avatax";
|
||||||
import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel";
|
import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel";
|
||||||
import packageJson from "../../../package.json";
|
import packageJson from "../../../package.json";
|
||||||
import { createLogger, Logger } from "../../lib/logger";
|
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 { CommitTransactionModel } from "avatax/lib/models/CommitTransactionModel";
|
||||||
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
||||||
import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo";
|
import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo";
|
||||||
|
@ -57,54 +57,22 @@ export class AvataxClient {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(config: AvataxConfig) {
|
constructor(config: AvataxConfig) {
|
||||||
this.logger = createLogger({ service: "AvataxClient" });
|
this.logger = createLogger({ location: "AvataxClient" });
|
||||||
this.logger.trace("AvataxClient constructor");
|
|
||||||
const { username, password } = config;
|
|
||||||
const credentials = {
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
};
|
|
||||||
const settings = createAvataxSettings(config);
|
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;
|
this.client = avataxClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTransaction({ model }: CreateTransactionArgs) {
|
async createTransaction({ model }: CreateTransactionArgs) {
|
||||||
this.logger.trace({ model }, "createTransaction called with:");
|
|
||||||
|
|
||||||
return this.client.createTransaction({ model });
|
return this.client.createTransaction({ model });
|
||||||
}
|
}
|
||||||
|
|
||||||
async commitTransaction(args: CommitTransactionArgs) {
|
async commitTransaction(args: CommitTransactionArgs) {
|
||||||
this.logger.trace(args, "commitTransaction called with:");
|
|
||||||
|
|
||||||
return this.client.commitTransaction(args);
|
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) {
|
async validateAddress({ address }: ValidateAddressArgs) {
|
||||||
this.logger.trace({ address }, "validateAddress called with:");
|
|
||||||
|
|
||||||
return this.client.resolveAddress(address);
|
return this.client.resolveAddress(address);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
|
@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
|
||||||
}),
|
|
||||||
});
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,44 +1,53 @@
|
||||||
import { z } from "zod";
|
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({
|
export const avataxConfigSchema = z.object({
|
||||||
name: z.string().min(1, { message: "Name requires at least one character." }),
|
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(),
|
isSandbox: z.boolean(),
|
||||||
companyCode: z.string().optional(),
|
companyCode: z.string().optional(),
|
||||||
isAutocommit: z.boolean(),
|
isAutocommit: z.boolean(),
|
||||||
shippingTaxCode: z.string().optional(),
|
shippingTaxCode: z.string().optional(),
|
||||||
|
credentials: avataxCredentialsSchema,
|
||||||
|
address: addressSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AvataxConfig = z.infer<typeof avataxConfigSchema>;
|
export type AvataxConfig = z.infer<typeof avataxConfigSchema>;
|
||||||
|
|
||||||
export const defaultAvataxConfig: AvataxConfig = {
|
export const defaultAvataxConfig: AvataxConfig = {
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
companyCode: "",
|
companyCode: "",
|
||||||
isSandbox: true,
|
isSandbox: false,
|
||||||
isAutocommit: false,
|
isAutocommit: false,
|
||||||
shippingTaxCode: "",
|
shippingTaxCode: "",
|
||||||
|
credentials: {
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
city: "",
|
||||||
|
country: "",
|
||||||
|
state: "",
|
||||||
|
street: "",
|
||||||
|
zip: "",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const avataxInstanceConfigSchema = z.object({
|
export const avataxConnectionSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
provider: z.literal("avatax"),
|
provider: z.literal("avatax"),
|
||||||
config: avataxConfigSchema,
|
config: avataxConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AvataxInstanceConfig = z.infer<typeof avataxInstanceConfigSchema>;
|
export type AvataxConnection = z.infer<typeof avataxConnectionSchema>;
|
||||||
|
|
||||||
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),
|
|
||||||
}));
|
|
100
apps/taxes/src/modules/avatax/avatax-connection.router.ts
Normal file
100
apps/taxes/src/modules/avatax/avatax-connection.router.ts
Normal 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;
|
||||||
|
}),
|
||||||
|
});
|
|
@ -1,5 +1,6 @@
|
||||||
import { avataxMockTransactionFactory } from "./avatax-mock-transaction-factory";
|
import { avataxMockTransactionFactory } from "./avatax-mock-transaction-factory";
|
||||||
|
|
||||||
|
// todo: refactor to mockGenerator
|
||||||
export const avataxMockFactory = {
|
export const avataxMockFactory = {
|
||||||
...avataxMockTransactionFactory,
|
...avataxMockTransactionFactory,
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,10 +4,9 @@ import {
|
||||||
TaxBaseFragment,
|
TaxBaseFragment,
|
||||||
} from "../../../generated/graphql";
|
} from "../../../generated/graphql";
|
||||||
import { Logger, createLogger } from "../../lib/logger";
|
import { Logger, createLogger } from "../../lib/logger";
|
||||||
import { ChannelConfig } from "../channels-configuration/channels-config";
|
|
||||||
import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
|
import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
|
||||||
import { AvataxClient } from "./avatax-client";
|
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 { AvataxCalculateTaxesAdapter } from "./calculate-taxes/avatax-calculate-taxes-adapter";
|
||||||
import { AvataxOrderCreatedAdapter } from "./order-created/avatax-order-created-adapter";
|
import { AvataxOrderCreatedAdapter } from "./order-created/avatax-order-created-adapter";
|
||||||
import { AvataxOrderFulfilledAdapter } from "./order-fulfilled/avatax-order-fulfilled-adapter";
|
import { AvataxOrderFulfilledAdapter } from "./order-fulfilled/avatax-order-fulfilled-adapter";
|
||||||
|
@ -19,48 +18,35 @@ export class AvataxWebhookService implements ProviderWebhookService {
|
||||||
|
|
||||||
constructor(config: AvataxConfig) {
|
constructor(config: AvataxConfig) {
|
||||||
this.logger = createLogger({
|
this.logger = createLogger({
|
||||||
service: "AvataxWebhookService",
|
location: "AvataxWebhookService",
|
||||||
});
|
});
|
||||||
const avataxClient = new AvataxClient(config);
|
const avataxClient = new AvataxClient(config);
|
||||||
|
|
||||||
this.logger.trace({ client: avataxClient }, "Internal Avatax client created");
|
|
||||||
|
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.client = avataxClient;
|
this.client = avataxClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
async calculateTaxes(taxBase: TaxBaseFragment, channelConfig: ChannelConfig) {
|
async calculateTaxes(taxBase: TaxBaseFragment) {
|
||||||
this.logger.debug({ taxBase, channelConfig }, "calculateTaxes called with:");
|
|
||||||
const adapter = new AvataxCalculateTaxesAdapter(this.config);
|
const adapter = new AvataxCalculateTaxesAdapter(this.config);
|
||||||
|
|
||||||
const response = await adapter.send({ channelConfig, taxBase });
|
const response = await adapter.send({ taxBase });
|
||||||
|
|
||||||
this.logger.debug({ response }, "calculateTaxes response:");
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createOrder(order: OrderCreatedSubscriptionFragment, channelConfig: ChannelConfig) {
|
async createOrder(order: OrderCreatedSubscriptionFragment) {
|
||||||
this.logger.debug({ order, channelConfig }, "createOrder called with:");
|
|
||||||
|
|
||||||
const adapter = new AvataxOrderCreatedAdapter(this.config);
|
const adapter = new AvataxOrderCreatedAdapter(this.config);
|
||||||
|
|
||||||
const response = await adapter.send({ channelConfig, order });
|
const response = await adapter.send({ order });
|
||||||
|
|
||||||
this.logger.debug({ response }, "createOrder response:");
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fulfillOrder(order: OrderFulfilledSubscriptionFragment, channelConfig: ChannelConfig) {
|
async fulfillOrder(order: OrderFulfilledSubscriptionFragment) {
|
||||||
this.logger.debug({ order, channelConfig }, "fulfillOrder called with:");
|
|
||||||
|
|
||||||
const adapter = new AvataxOrderFulfilledAdapter(this.config);
|
const adapter = new AvataxOrderFulfilledAdapter(this.config);
|
||||||
|
|
||||||
const response = await adapter.send({ order });
|
const response = await adapter.send({ order });
|
||||||
|
|
||||||
this.logger.debug({ response }, "fulfillOrder response:");
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,39 +1,43 @@
|
||||||
import { TaxBaseFragment } from "../../../../generated/graphql";
|
import { TaxBaseFragment } from "../../../../generated/graphql";
|
||||||
import { Logger, createLogger } from "../../../lib/logger";
|
import { Logger, createLogger } from "../../../lib/logger";
|
||||||
import { ChannelConfig } from "../../channels-configuration/channels-config";
|
|
||||||
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
|
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
|
||||||
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
|
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
|
||||||
import { AvataxClient, CreateTransactionArgs } from "../avatax-client";
|
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 { AvataxCalculateTaxesPayloadTransformer } from "./avatax-calculate-taxes-payload-transformer";
|
||||||
import { AvataxCalculateTaxesResponseTransformer } from "./avatax-calculate-taxes-response-transformer";
|
import { AvataxCalculateTaxesResponseTransformer } from "./avatax-calculate-taxes-response-transformer";
|
||||||
|
|
||||||
export const SHIPPING_ITEM_CODE = "Shipping";
|
export const SHIPPING_ITEM_CODE = "Shipping";
|
||||||
|
|
||||||
export type Payload = {
|
export type AvataxCalculateTaxesPayload = {
|
||||||
taxBase: TaxBaseFragment;
|
taxBase: TaxBaseFragment;
|
||||||
channelConfig: ChannelConfig;
|
|
||||||
config: AvataxConfig;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Target = CreateTransactionArgs;
|
export type AvataxCalculateTaxesTarget = CreateTransactionArgs;
|
||||||
export type Response = CalculateTaxesResponse;
|
export type AvataxCalculateTaxesResponse = CalculateTaxesResponse;
|
||||||
|
|
||||||
export class AvataxCalculateTaxesAdapter implements WebhookAdapter<Payload, Response> {
|
export class AvataxCalculateTaxesAdapter
|
||||||
|
implements WebhookAdapter<AvataxCalculateTaxesPayload, AvataxCalculateTaxesResponse>
|
||||||
|
{
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
constructor(private readonly config: AvataxConfig) {
|
constructor(private readonly config: AvataxConfig) {
|
||||||
this.logger = createLogger({ service: "AvataxCalculateTaxesAdapter" });
|
this.logger = createLogger({ location: "AvataxCalculateTaxesAdapter" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(payload: Pick<Payload, "channelConfig" | "taxBase">): Promise<Response> {
|
async send(payload: AvataxCalculateTaxesPayload): Promise<AvataxCalculateTaxesResponse> {
|
||||||
this.logger.debug({ payload }, "send called with:");
|
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
|
||||||
const payloadTransformer = new AvataxCalculateTaxesPayloadTransformer();
|
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 client = new AvataxClient(this.config);
|
||||||
const response = await client.createTransaction(target);
|
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 responseTransformer = new AvataxCalculateTaxesResponseTransformer();
|
||||||
const transformedResponse = responseTransformer.transform(response);
|
const transformedResponse = responseTransformer.transform(response);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
||||||
import { TaxBaseFragment } from "../../../../generated/graphql";
|
import { TaxBaseFragment } from "../../../../generated/graphql";
|
||||||
import { ChannelConfig } from "../../channels-configuration/channels-config";
|
import { ChannelConfig } from "../../channel-configuration/channel-config";
|
||||||
import { DocumentStatus } from "avatax/lib/enums/DocumentStatus";
|
import { DocumentStatus } from "avatax/lib/enums/DocumentStatus";
|
||||||
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
||||||
import { AdjustmentReason } from "avatax/lib/enums/AdjustmentReason";
|
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 { ChargedTo } from "avatax/lib/enums/ChargedTo";
|
||||||
import { JurisdictionType } from "avatax/lib/enums/JurisdictionType";
|
import { JurisdictionType } from "avatax/lib/enums/JurisdictionType";
|
||||||
import { BoundaryLevel } from "avatax/lib/enums/BoundaryLevel";
|
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;
|
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 = {
|
const defaultTransactionModel: TransactionModel = {
|
||||||
id: 0,
|
id: 0,
|
||||||
code: "aec372bb-f3b3-40fb-9d84-2b46cd67e516",
|
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 = {
|
const testingScenariosMap = {
|
||||||
default: {
|
default: {
|
||||||
taxBase: defaultTaxBase,
|
taxBase: defaultTaxBase,
|
||||||
channelConfig: defaultChannelConfig,
|
|
||||||
avataxConfig: defaultAvataxConfig,
|
|
||||||
response: defaultTransactionModel,
|
response: defaultTransactionModel,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -972,17 +950,17 @@ export class AvataxCalculateTaxesMockGenerator {
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
generateChannelConfig = (overrides: Partial<ChannelConfig> = {}): ChannelConfig =>
|
generateChannelConfig = (overrides: Partial<ChannelConfig> = {}): ChannelConfig => {
|
||||||
structuredClone({
|
const mockGenerator = new ChannelConfigMockGenerator();
|
||||||
...testingScenariosMap[this.scenario].channelConfig,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
generateAvataxConfig = (overrides: Partial<AvataxConfig> = {}): AvataxConfig =>
|
return mockGenerator.generateChannelConfig(overrides);
|
||||||
structuredClone({
|
};
|
||||||
...testingScenariosMap[this.scenario].avataxConfig,
|
|
||||||
...overrides,
|
generateAvataxConfig = (overrides: Partial<AvataxConfig> = {}): AvataxConfig => {
|
||||||
});
|
const mockGenerator = new AvataxConfigMockGenerator();
|
||||||
|
|
||||||
|
return mockGenerator.generateAvataxConfig(overrides);
|
||||||
|
};
|
||||||
|
|
||||||
generateResponse = (overrides: Partial<TransactionModel> = {}): TransactionModel =>
|
generateResponse = (overrides: Partial<TransactionModel> = {}): TransactionModel =>
|
||||||
structuredClone({
|
structuredClone({
|
||||||
|
|
|
@ -13,8 +13,7 @@ describe("AvataxCalculateTaxesPayloadTransformer", () => {
|
||||||
|
|
||||||
const payload = new AvataxCalculateTaxesPayloadTransformer().transform({
|
const payload = new AvataxCalculateTaxesPayloadTransformer().transform({
|
||||||
taxBase: taxBaseMock,
|
taxBase: taxBaseMock,
|
||||||
channelConfig: mockGenerator.generateChannelConfig(),
|
providerConfig: avataxConfigMock,
|
||||||
config: avataxConfigMock,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.model.discount).toEqual(10);
|
expect(payload.model.discount).toEqual(10);
|
||||||
|
@ -26,8 +25,7 @@ describe("AvataxCalculateTaxesPayloadTransformer", () => {
|
||||||
|
|
||||||
const payload = new AvataxCalculateTaxesPayloadTransformer().transform({
|
const payload = new AvataxCalculateTaxesPayloadTransformer().transform({
|
||||||
taxBase: taxBaseMock,
|
taxBase: taxBaseMock,
|
||||||
channelConfig: mockGenerator.generateChannelConfig(),
|
providerConfig: avataxConfigMock,
|
||||||
config: avataxConfigMock,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.model.discount).toEqual(0);
|
expect(payload.model.discount).toEqual(0);
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
|
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
||||||
import { LineItemModel } from "avatax/lib/models/LineItemModel";
|
import { LineItemModel } from "avatax/lib/models/LineItemModel";
|
||||||
import { TaxBaseFragment } from "../../../../generated/graphql";
|
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 { 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[] {
|
export function mapPayloadLines(taxBase: TaxBaseFragment, config: AvataxConfig): LineItemModel[] {
|
||||||
const isDiscounted = taxBase.discounts.length > 0;
|
const isDiscounted = taxBase.discounts.length > 0;
|
||||||
|
@ -35,22 +36,26 @@ export function mapPayloadLines(taxBase: TaxBaseFragment, config: AvataxConfig):
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AvataxCalculateTaxesPayloadTransformer {
|
export class AvataxCalculateTaxesPayloadTransformer {
|
||||||
transform(props: Payload): Target {
|
transform({
|
||||||
const { taxBase, channelConfig, config } = props;
|
taxBase,
|
||||||
|
providerConfig,
|
||||||
|
}: {
|
||||||
|
taxBase: TaxBaseFragment;
|
||||||
|
providerConfig: AvataxConfig;
|
||||||
|
}): CreateTransactionArgs {
|
||||||
return {
|
return {
|
||||||
model: {
|
model: {
|
||||||
type: DocumentType.SalesOrder,
|
type: DocumentType.SalesOrder,
|
||||||
customerCode: taxBase.sourceObject.user?.id ?? "",
|
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: 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: {
|
addresses: {
|
||||||
shipFrom: avataxAddressFactory.fromChannelAddress(channelConfig.address),
|
shipFrom: avataxAddressFactory.fromChannelAddress(providerConfig.address),
|
||||||
shipTo: avataxAddressFactory.fromSaleorAddress(taxBase.address!),
|
shipTo: avataxAddressFactory.fromSaleorAddress(taxBase.address!),
|
||||||
},
|
},
|
||||||
currencyCode: taxBase.currency,
|
currencyCode: taxBase.currency,
|
||||||
lines: mapPayloadLines(taxBase, config),
|
lines: mapPayloadLines(taxBase, providerConfig),
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
discount: discountUtils.sumDiscounts(
|
discount: discountUtils.sumDiscounts(
|
||||||
taxBase.discounts.map((discount) => discount.amount.amount)
|
taxBase.discounts.map((discount) => discount.amount.amount)
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
||||||
import { numbers } from "../../taxes/numbers";
|
import { numbers } from "../../taxes/numbers";
|
||||||
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
|
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 {
|
export class AvataxCalculateTaxesResponseLinesTransformer {
|
||||||
transform(transaction: TransactionModel): Response["lines"] {
|
transform(transaction: TransactionModel): CalculateTaxesResponse["lines"] {
|
||||||
const productLines = transaction.lines?.filter((line) => line.itemCode !== SHIPPING_ITEM_CODE);
|
const productLines = transaction.lines?.filter((line) => line.itemCode !== SHIPPING_ITEM_CODE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
||||||
import { numbers } from "../../taxes/numbers";
|
import { numbers } from "../../taxes/numbers";
|
||||||
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
|
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 {
|
export class AvataxCalculateTaxesResponseShippingTransformer {
|
||||||
transform(
|
transform(
|
||||||
transaction: TransactionModel
|
transaction: TransactionModel
|
||||||
): Pick<
|
): Pick<
|
||||||
Response,
|
CalculateTaxesResponse,
|
||||||
"shipping_price_gross_amount" | "shipping_price_net_amount" | "shipping_tax_rate"
|
"shipping_price_gross_amount" | "shipping_price_net_amount" | "shipping_tax_rate"
|
||||||
> {
|
> {
|
||||||
const shippingLine = transaction.lines?.find((line) => line.itemCode === SHIPPING_ITEM_CODE);
|
const shippingLine = transaction.lines?.find((line) => line.itemCode === SHIPPING_ITEM_CODE);
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
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 { AvataxCalculateTaxesResponseLinesTransformer } from "./avatax-calculate-taxes-response-lines-transformer";
|
||||||
import { AvataxCalculateTaxesResponseShippingTransformer } from "./avatax-calculate-taxes-response-shipping-transformer";
|
import { AvataxCalculateTaxesResponseShippingTransformer } from "./avatax-calculate-taxes-response-shipping-transformer";
|
||||||
|
|
||||||
export class AvataxCalculateTaxesResponseTransformer {
|
export class AvataxCalculateTaxesResponseTransformer {
|
||||||
transform(response: TransactionModel): Response {
|
transform(response: TransactionModel): CalculateTaxesResponse {
|
||||||
const shippingTransformer = new AvataxCalculateTaxesResponseShippingTransformer();
|
const shippingTransformer = new AvataxCalculateTaxesResponseShippingTransformer();
|
||||||
const shipping = shippingTransformer.transform(response);
|
const shipping = shippingTransformer.transform(response);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.");
|
||||||
|
});
|
||||||
|
});
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,38 +1,41 @@
|
||||||
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
|
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 { CreateOrderResponse } from "../../taxes/tax-provider-webhook";
|
||||||
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
|
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
|
||||||
import { AvataxClient, CreateTransactionArgs } from "../avatax-client";
|
import { AvataxClient } from "../avatax-client";
|
||||||
import { AvataxConfig } from "../avatax-config";
|
import { AvataxConfig } from "../avatax-connection-schema";
|
||||||
import { AvataxOrderCreatedResponseTransformer } from "./avatax-order-created-response-transformer";
|
|
||||||
import { AvataxOrderCreatedPayloadTransformer } from "./avatax-order-created-payload-transformer";
|
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;
|
order: OrderCreatedSubscriptionFragment;
|
||||||
channelConfig: ChannelConfig;
|
|
||||||
config: AvataxConfig;
|
|
||||||
};
|
};
|
||||||
export type Target = CreateTransactionArgs;
|
type AvataxOrderCreatedResponse = CreateOrderResponse;
|
||||||
type Response = CreateOrderResponse;
|
|
||||||
|
|
||||||
export class AvataxOrderCreatedAdapter implements WebhookAdapter<Payload, Response> {
|
export class AvataxOrderCreatedAdapter
|
||||||
|
implements WebhookAdapter<AvataxOrderCreatedPayload, AvataxOrderCreatedResponse>
|
||||||
|
{
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(private readonly config: AvataxConfig) {
|
constructor(private readonly config: AvataxConfig) {
|
||||||
this.logger = createLogger({ service: "AvataxOrderCreatedAdapter" });
|
this.logger = createLogger({ location: "AvataxOrderCreatedAdapter" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(payload: Pick<Payload, "channelConfig" | "order">): Promise<Response> {
|
async send(payload: AvataxOrderCreatedPayload): Promise<AvataxOrderCreatedResponse> {
|
||||||
this.logger.debug({ payload }, "send called with:");
|
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
|
||||||
|
|
||||||
const payloadTransformer = new AvataxOrderCreatedPayloadTransformer();
|
const payloadTransformer = new AvataxOrderCreatedPayloadTransformer(this.config);
|
||||||
const target = payloadTransformer.transform({ ...payload, config: 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 client = new AvataxClient(this.config);
|
||||||
const response = await client.createTransaction(target);
|
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 responseTransformer = new AvataxOrderCreatedResponseTransformer();
|
||||||
const transformedResponse = responseTransformer.transform(response);
|
const transformedResponse = responseTransformer.transform(response);
|
||||||
|
|
|
@ -1,40 +1,26 @@
|
||||||
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
||||||
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
|
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 { orderCreatedTransactionMock } from "./avatax-order-created-response-transaction-mock";
|
||||||
import { AvataxConfig } from "../avatax-config";
|
import { AvataxConfig } from "../avatax-connection-schema";
|
||||||
import { defaultOrder } from "../../../mocks";
|
import { defaultOrder } from "../../../mocks";
|
||||||
|
import { AvataxConfigMockGenerator } from "../avatax-config-mock-generator";
|
||||||
|
|
||||||
const defaultChannelConfig: ChannelConfig = {
|
const defaultChannelConfig: ChannelConfig = {
|
||||||
providerInstanceId: "aa5293e5-7f5d-4782-a619-222ead918e50",
|
id: "1",
|
||||||
enabled: false,
|
config: {
|
||||||
address: {
|
providerConnectionId: "aa5293e5-7f5d-4782-a619-222ead918e50",
|
||||||
country: "US",
|
slug: "default-channel",
|
||||||
zip: "95008",
|
|
||||||
state: "CA",
|
|
||||||
city: "Campbell",
|
|
||||||
street: "33 N. First Street",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultOrderCreatedResponse: TransactionModel = orderCreatedTransactionMock;
|
const defaultOrderCreatedResponse: TransactionModel = orderCreatedTransactionMock;
|
||||||
|
|
||||||
const defaultAvataxConfig: AvataxConfig = {
|
|
||||||
companyCode: "DEFAULT",
|
|
||||||
isAutocommit: false,
|
|
||||||
isSandbox: true,
|
|
||||||
name: "Avatax-1",
|
|
||||||
password: "password",
|
|
||||||
username: "username",
|
|
||||||
shippingTaxCode: "FR000000",
|
|
||||||
};
|
|
||||||
|
|
||||||
const testingScenariosMap = {
|
const testingScenariosMap = {
|
||||||
default: {
|
default: {
|
||||||
order: defaultOrder,
|
order: defaultOrder,
|
||||||
channelConfig: defaultChannelConfig,
|
channelConfig: defaultChannelConfig,
|
||||||
response: defaultOrderCreatedResponse,
|
response: defaultOrderCreatedResponse,
|
||||||
avataxConfig: defaultAvataxConfig,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -56,11 +42,11 @@ export class AvataxOrderCreatedMockGenerator {
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
generateAvataxConfig = (overrides: Partial<AvataxConfig> = {}): AvataxConfig =>
|
generateAvataxConfig = (overrides: Partial<AvataxConfig> = {}): AvataxConfig => {
|
||||||
structuredClone({
|
const mockGenerator = new AvataxConfigMockGenerator();
|
||||||
...testingScenariosMap[this.scenario].avataxConfig,
|
|
||||||
...overrides,
|
return mockGenerator.generateAvataxConfig(overrides);
|
||||||
});
|
};
|
||||||
|
|
||||||
generateResponse = (overrides: Partial<TransactionModel> = {}): TransactionModel =>
|
generateResponse = (overrides: Partial<TransactionModel> = {}): TransactionModel =>
|
||||||
structuredClone({
|
structuredClone({
|
||||||
|
|
|
@ -22,10 +22,10 @@ const channelConfigMock = mockGenerator.generateChannelConfig();
|
||||||
|
|
||||||
describe("AvataxOrderCreatedPayloadTransformer", () => {
|
describe("AvataxOrderCreatedPayloadTransformer", () => {
|
||||||
it("returns lines with discounted: true when there are discounts", () => {
|
it("returns lines with discounted: true when there are discounts", () => {
|
||||||
const transformer = new AvataxOrderCreatedPayloadTransformer();
|
const transformer = new AvataxOrderCreatedPayloadTransformer(avataxConfigMock);
|
||||||
const payloadMock = {
|
const payloadMock = {
|
||||||
order: discountedOrderMock,
|
order: discountedOrderMock,
|
||||||
config: avataxConfigMock,
|
providerConfig: avataxConfigMock,
|
||||||
channelConfig: channelConfigMock,
|
channelConfig: channelConfigMock,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -37,10 +37,10 @@ describe("AvataxOrderCreatedPayloadTransformer", () => {
|
||||||
expect(check).toBe(true);
|
expect(check).toBe(true);
|
||||||
});
|
});
|
||||||
it("returns lines with discounted: false when there are no discounts", () => {
|
it("returns lines with discounted: false when there are no discounts", () => {
|
||||||
const transformer = new AvataxOrderCreatedPayloadTransformer();
|
const transformer = new AvataxOrderCreatedPayloadTransformer(avataxConfigMock);
|
||||||
const payloadMock = {
|
const payloadMock = {
|
||||||
order: orderMock,
|
order: orderMock,
|
||||||
config: avataxConfigMock,
|
providerConfig: avataxConfigMock,
|
||||||
channelConfig: channelConfigMock,
|
channelConfig: channelConfigMock,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { LineItemModel } from "avatax/lib/models/LineItemModel";
|
import { LineItemModel } from "avatax/lib/models/LineItemModel";
|
||||||
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
|
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
|
||||||
import { numbers } from "../../taxes/numbers";
|
import { numbers } from "../../taxes/numbers";
|
||||||
import { AvataxConfig } from "../avatax-config";
|
import { AvataxConfig } from "../avatax-connection-schema";
|
||||||
import { avataxAddressFactory } from "../address-factory";
|
import { avataxAddressFactory } from "../address-factory";
|
||||||
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
||||||
import { Payload, Target } from "./avatax-order-created-adapter";
|
|
||||||
import { discountUtils } from "../../taxes/discount-utils";
|
import { discountUtils } from "../../taxes/discount-utils";
|
||||||
|
import { CreateTransactionArgs } from "../avatax-client";
|
||||||
|
|
||||||
const SHIPPING_ITEM_CODE = "Shipping";
|
const SHIPPING_ITEM_CODE = "Shipping";
|
||||||
|
|
||||||
|
@ -49,24 +49,25 @@ export function mapLines(
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AvataxOrderCreatedPayloadTransformer {
|
export class AvataxOrderCreatedPayloadTransformer {
|
||||||
transform = ({ order, channelConfig, config }: Payload): Target => {
|
constructor(private readonly providerConfig: AvataxConfig) {}
|
||||||
|
transform = ({ order }: { order: OrderCreatedSubscriptionFragment }): CreateTransactionArgs => {
|
||||||
return {
|
return {
|
||||||
model: {
|
model: {
|
||||||
type: DocumentType.SalesInvoice,
|
type: DocumentType.SalesInvoice,
|
||||||
customerCode:
|
customerCode:
|
||||||
order.user?.id ??
|
order.user?.id ??
|
||||||
"" /* In Saleor Avatax plugin, the customer code is 0. In Taxes App, we set it to the 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: 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: {
|
addresses: {
|
||||||
shipFrom: avataxAddressFactory.fromChannelAddress(channelConfig.address),
|
shipFrom: avataxAddressFactory.fromChannelAddress(this.providerConfig.address),
|
||||||
// billing or shipping address?
|
// billing or shipping address?
|
||||||
shipTo: avataxAddressFactory.fromSaleorAddress(order.billingAddress!),
|
shipTo: avataxAddressFactory.fromSaleorAddress(order.billingAddress!),
|
||||||
},
|
},
|
||||||
currencyCode: order.total.currency,
|
currencyCode: order.total.currency,
|
||||||
email: order.user?.email ?? "",
|
email: order.user?.email ?? "",
|
||||||
lines: mapLines(order, config),
|
lines: mapLines(order, this.providerConfig),
|
||||||
date: new Date(order.created),
|
date: new Date(order.created),
|
||||||
discount: discountUtils.sumDiscounts(
|
discount: discountUtils.sumDiscounts(
|
||||||
order.discounts.map((discount) => discount.amount.amount)
|
order.discounts.map((discount) => discount.amount.amount)
|
||||||
|
|
|
@ -2,34 +2,40 @@ import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphq
|
||||||
import { Logger, createLogger } from "../../../lib/logger";
|
import { Logger, createLogger } from "../../../lib/logger";
|
||||||
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
|
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
|
||||||
import { AvataxClient, CommitTransactionArgs } from "../avatax-client";
|
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 { AvataxOrderFulfilledPayloadTransformer } from "./avatax-order-fulfilled-payload-transformer";
|
||||||
import { AvataxOrderFulfilledResponseTransformer } from "./avatax-order-fulfilled-response-transformer";
|
import { AvataxOrderFulfilledResponseTransformer } from "./avatax-order-fulfilled-response-transformer";
|
||||||
|
|
||||||
export type Payload = {
|
export type AvataxOrderFulfilledPayload = {
|
||||||
order: OrderFulfilledSubscriptionFragment;
|
order: OrderFulfilledSubscriptionFragment;
|
||||||
config: AvataxConfig;
|
|
||||||
};
|
};
|
||||||
export type Target = CommitTransactionArgs;
|
export type AvataxOrderFulfilledTarget = CommitTransactionArgs;
|
||||||
export type Response = { ok: true };
|
export type AvataxOrderFulfilledResponse = { ok: true };
|
||||||
|
|
||||||
export class AvataxOrderFulfilledAdapter implements WebhookAdapter<Payload, Response> {
|
export class AvataxOrderFulfilledAdapter
|
||||||
|
implements WebhookAdapter<AvataxOrderFulfilledPayload, AvataxOrderFulfilledResponse>
|
||||||
|
{
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(private readonly config: AvataxConfig) {
|
constructor(private readonly config: AvataxConfig) {
|
||||||
this.logger = createLogger({ service: "AvataxOrderFulfilledAdapter" });
|
this.logger = createLogger({ location: "AvataxOrderFulfilledAdapter" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(payload: Pick<Payload, "order">): Promise<Response> {
|
async send(payload: AvataxOrderFulfilledPayload): Promise<AvataxOrderFulfilledResponse> {
|
||||||
this.logger.debug({ payload }, "send called with:");
|
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
|
||||||
|
|
||||||
const payloadTransformer = new AvataxOrderFulfilledPayloadTransformer();
|
const payloadTransformer = new AvataxOrderFulfilledPayloadTransformer(this.config);
|
||||||
const target = payloadTransformer.transform({ ...payload, config: 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 client = new AvataxClient(this.config);
|
||||||
const response = await client.commitTransaction(target);
|
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 responseTransformer = new AvataxOrderFulfilledResponseTransformer();
|
||||||
const transformedResponse = responseTransformer.transform(response);
|
const transformedResponse = responseTransformer.transform(response);
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql";
|
import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql";
|
||||||
import { AvataxConfig } from "../avatax-config";
|
import { AvataxConfig } from "../avatax-connection-schema";
|
||||||
import { Payload } from "./avatax-order-fulfilled-adapter";
|
|
||||||
import {
|
import {
|
||||||
AvataxOrderFulfilledPayloadTransformer,
|
AvataxOrderFulfilledPayloadTransformer,
|
||||||
PROVIDER_ORDER_ID_KEY,
|
PROVIDER_ORDER_ID_KEY,
|
||||||
|
@ -16,9 +15,18 @@ const MOCK_AVATAX_CONFIG: AvataxConfig = {
|
||||||
isAutocommit: false,
|
isAutocommit: false,
|
||||||
isSandbox: true,
|
isSandbox: true,
|
||||||
name: "Avatax-1",
|
name: "Avatax-1",
|
||||||
|
shippingTaxCode: "FR000000",
|
||||||
|
address: {
|
||||||
|
country: "US",
|
||||||
|
zip: "10118",
|
||||||
|
state: "NY",
|
||||||
|
city: "New York",
|
||||||
|
street: "350 5th Avenue",
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
password: "password",
|
password: "password",
|
||||||
username: "username",
|
username: "username",
|
||||||
shippingTaxCode: "FR000000",
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const MOCKED_METADATA: OrderFulfilledSubscriptionFragment["privateMetadata"] = [
|
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,
|
order: ORDER_FULFILLED_MOCK,
|
||||||
config: MOCK_AVATAX_CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("AvataxOrderFulfilledPayloadTransformer", () => {
|
describe("AvataxOrderFulfilledPayloadTransformer", () => {
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
import { OrderFulfilledSubscriptionFragment } from "../../../../generated/graphql";
|
|
||||||
import { DocumentType } from "avatax/lib/enums/DocumentType";
|
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.
|
// * 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 {
|
export class AvataxOrderFulfilledPayloadTransformer {
|
||||||
transform({ order, config }: Payload): Target {
|
constructor(private readonly config: AvataxConfig) {}
|
||||||
|
transform({ order }: AvataxOrderFulfilledPayload): AvataxOrderFulfilledTarget {
|
||||||
const transactionCode = getTransactionCodeFromMetadata(order.privateMetadata);
|
const transactionCode = getTransactionCodeFromMetadata(order.privateMetadata);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transactionCode,
|
transactionCode,
|
||||||
companyCode: config.companyCode ?? "",
|
companyCode: this.config.companyCode ?? "",
|
||||||
documentType: DocumentType.SalesInvoice,
|
documentType: DocumentType.SalesInvoice,
|
||||||
model: {
|
model: {
|
||||||
commit: true,
|
commit: true,
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
import { TransactionModel } from "avatax/lib/models/TransactionModel";
|
||||||
import { Response } from "./avatax-order-fulfilled-adapter";
|
import { AvataxOrderFulfilledResponse } from "./avatax-order-fulfilled-adapter";
|
||||||
|
|
||||||
export class AvataxOrderFulfilledResponseTransformer {
|
export class AvataxOrderFulfilledResponseTransformer {
|
||||||
transform(response: TransactionModel): Response {
|
transform(response: TransactionModel): AvataxOrderFulfilledResponse {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,318 +1,223 @@
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import { TextLink } from "@saleor/apps-ui";
|
||||||
FormHelperText,
|
import { Box, Button, Divider, Text } from "@saleor/macaw-ui/next";
|
||||||
Grid,
|
import { Input } from "@saleor/react-hook-form-macaw";
|
||||||
InputLabel,
|
|
||||||
Switch,
|
|
||||||
TextField,
|
|
||||||
TextFieldProps,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
import { Delete, Save } from "@material-ui/icons";
|
|
||||||
import { Button, makeStyles } from "@saleor/macaw-ui";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { AppCard } from "../../ui/app-card";
|
||||||
import { useInstanceId } from "../../taxes/tax-context";
|
import { AppToggle } from "../../ui/app-toggle";
|
||||||
import { trpcClient } from "../../trpc/trpc-client";
|
import { CountrySelect } from "../../ui/country-select";
|
||||||
import { AppLink } from "../../ui/app-link";
|
import { ProviderLabel } from "../../ui/provider-label";
|
||||||
import { avataxConfigSchema } from "../avatax-config";
|
import { AvataxConfig, avataxConfigSchema, defaultAvataxConfig } from "../avatax-connection-schema";
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
const HelperText = ({ children }: { children: React.ReactNode }) => {
|
||||||
reverseRow: {
|
return (
|
||||||
display: "flex",
|
<Text color="textNeutralSubdued" fontWeight={"captionLarge"}>
|
||||||
flexDirection: "row-reverse",
|
{children}
|
||||||
gap: theme.spacing(1),
|
</Text>
|
||||||
},
|
);
|
||||||
}));
|
|
||||||
|
|
||||||
const schema = avataxConfigSchema;
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
const defaultValues: FormValues = {
|
|
||||||
companyCode: "",
|
|
||||||
isAutocommit: false,
|
|
||||||
isSandbox: false,
|
|
||||||
password: "",
|
|
||||||
username: "",
|
|
||||||
name: "",
|
|
||||||
shippingTaxCode: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AvataxConfigurationForm = () => {
|
type AvataxConfigurationFormProps = {
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
onSubmit: (data: AvataxConfig) => void;
|
||||||
const [isWarningDialogOpen, setIsWarningDialogOpen] = React.useState(false);
|
defaultValues: AvataxConfig;
|
||||||
const styles = useStyles();
|
isLoading: boolean;
|
||||||
const { handleSubmit, reset, control, formState } = useForm<FormValues>({
|
cancelButton: React.ReactNode;
|
||||||
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 resetInstanceId = () => {
|
export const AvataxConfigurationForm = (props: AvataxConfigurationFormProps) => {
|
||||||
setInstanceId(null);
|
const { handleSubmit, control, formState, reset } = useForm({
|
||||||
};
|
defaultValues: defaultAvataxConfig,
|
||||||
|
resolver: zodResolver(avataxConfigSchema),
|
||||||
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (instance) {
|
reset(props.defaultValues);
|
||||||
const { config } = instance;
|
}, [props.defaultValues, reset]);
|
||||||
|
|
||||||
reset(config);
|
const submitHandler = React.useCallback(
|
||||||
} else {
|
(data: AvataxConfig) => {
|
||||||
reset(defaultValues);
|
props.onSubmit(data);
|
||||||
}
|
|
||||||
}, [instance, reset]);
|
|
||||||
|
|
||||||
const { mutate: createMutation, isLoading: isCreateLoading } =
|
|
||||||
trpcClient.avataxConfiguration.post.useMutation({
|
|
||||||
onSuccess({ id }) {
|
|
||||||
setInstanceId(id);
|
|
||||||
refetchProvidersConfigurationData();
|
|
||||||
notifySuccess("Success", "Saved app configuration");
|
|
||||||
},
|
},
|
||||||
onError(error) {
|
[props]
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AppCard>
|
||||||
<form autoComplete="off" onSubmit={handleSubmit(onSubmit)}>
|
<Box marginBottom={8}>
|
||||||
<Grid container spacing={2}>
|
<ProviderLabel name="avatax" />
|
||||||
<Grid item xs={12}>
|
</Box>
|
||||||
<Controller
|
|
||||||
|
<form onSubmit={handleSubmit(submitHandler)}>
|
||||||
|
<Input
|
||||||
|
control={control}
|
||||||
name="name"
|
name="name"
|
||||||
control={control}
|
|
||||||
defaultValue={defaultValues.name}
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
required
|
required
|
||||||
type="text"
|
label="Configuration name *"
|
||||||
{...field}
|
helperText={formState.errors.name?.message}
|
||||||
label="Instance name"
|
|
||||||
{...textFieldProps}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<HelperText>Unique identifier for your provider.</HelperText>
|
||||||
/>
|
<Divider marginY={8} />
|
||||||
{formState.errors.name && (
|
<Text marginBottom={4} as="h3" variant="heading">
|
||||||
<FormHelperText error>{formState.errors.name.message}</FormHelperText>
|
Credentials
|
||||||
)}
|
</Text>
|
||||||
</Grid>
|
<Box display="grid" gridTemplateColumns={2} gap={12}>
|
||||||
<Grid item xs={12}>
|
<Box paddingY={4} display={"flex"} flexDirection={"column"} gap={10}>
|
||||||
<InputLabel>
|
<div>
|
||||||
Sandbox
|
<Input
|
||||||
<Controller
|
|
||||||
name={"isSandbox"}
|
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={defaultValues.isSandbox}
|
name="credentials.username"
|
||||||
render={({ field }) => (
|
required
|
||||||
<Switch
|
label="Username *"
|
||||||
{...field}
|
helperText={formState.errors.credentials?.username?.message}
|
||||||
checked={field.value}
|
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<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>
|
<HelperText>
|
||||||
<FormHelperText>
|
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{" "}
|
Toggling between{" "}
|
||||||
<AppLink
|
<TextLink
|
||||||
href={
|
href="https://developer.avalara.com/erp-integration-guide/sales-tax-badge/authentication-in-avatax/sandbox-vs-production/"
|
||||||
"https://developer.avalara.com/erp-integration-guide/sales-tax-badge/authentication-in-avatax/sandbox-vs-production/"
|
newTab
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<q>Production</q> and <q>Sandbox</q>
|
<q>Production</q> and <q>Sandbox</q>
|
||||||
</AppLink>{" "}
|
</TextLink>{" "}
|
||||||
environments.{" "}
|
environment.
|
||||||
</FormHelperText>
|
</HelperText>
|
||||||
</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/"
|
|
||||||
}
|
}
|
||||||
>
|
name="isSandbox"
|
||||||
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} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{formState.errors.password && (
|
<AppToggle
|
||||||
<FormHelperText error>{formState.errors.password.message}</FormHelperText>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Controller
|
|
||||||
name="companyCode"
|
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={defaultValues.companyCode}
|
label="Autocommit"
|
||||||
render={({ field }) => (
|
helperText={
|
||||||
<TextField type="text" {...field} label="Company code" {...textFieldProps} />
|
<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>
|
</Box>
|
||||||
{"When not provided, the default company will be used. "}
|
</Box>
|
||||||
<AppLink href="https://developer.avalara.com/erp-integration-guide/sales-tax-badge/transactions/simple-transactions/company-codes/">
|
<Divider marginY={8} />
|
||||||
Read more
|
<Text marginBottom={4} as="h3" variant="heading">
|
||||||
</AppLink>{" "}
|
Address
|
||||||
about company codes.
|
</Text>
|
||||||
</FormHelperText>
|
<Box paddingY={4} display={"grid"} gridTemplateColumns={2} gap={12}>
|
||||||
{formState.errors.companyCode && (
|
<Input
|
||||||
<FormHelperText error>{formState.errors.companyCode.message}</FormHelperText>
|
control={control}
|
||||||
)}
|
required
|
||||||
</Grid>
|
name="address.street"
|
||||||
<Grid item xs={12}>
|
label="Street *"
|
||||||
<Controller
|
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"
|
name="shippingTaxCode"
|
||||||
control={control}
|
label="Shipping tax code"
|
||||||
defaultValue={defaultValues.shippingTaxCode}
|
helperText={formState.errors.shippingTaxCode?.message}
|
||||||
render={({ field }) => (
|
|
||||||
<TextField type="text" {...field} label="Shipping tax code" {...textFieldProps} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<FormHelperText>
|
<HelperText>
|
||||||
{"Tax code that for the shipping line sent to Avatax. "}
|
Tax code that for the shipping line sent to Avatax.{" "}
|
||||||
<AppLink href="https://taxcode.avatax.avalara.com">
|
<TextLink newTab href="https://taxcode.avatax.avalara.com">
|
||||||
Must match Avatax tax codes format.
|
Must match Avatax tax codes format.
|
||||||
</AppLink>
|
</TextLink>
|
||||||
</FormHelperText>
|
</HelperText>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</form>
|
||||||
{/* <DeleteProviderDialog
|
</AppCard>
|
||||||
isOpen={isWarningDialogOpen}
|
|
||||||
onClose={closeWarningDialog}
|
|
||||||
onCancel={closeWarningDialog}
|
|
||||||
onConfirm={deleteProvider}
|
|
||||||
/> */}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
import { AvataxConfigurationForm } from "./avatax-configuration-form";
|
|
||||||
|
|
||||||
export const AvataxConfiguration = () => {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h2>Avatax configuration</h2>
|
|
||||||
<AvataxConfigurationForm />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
52
apps/taxes/src/modules/avatax/ui/avatax-instructions.tsx
Normal file
52
apps/taxes/src/modules/avatax/ui/avatax-instructions.tsx
Normal 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 "sandbox mode"
|
||||||
|
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>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
107
apps/taxes/src/modules/avatax/ui/edit-avatax-configuration.tsx
Normal file
107
apps/taxes/src/modules/avatax/ui/edit-avatax-configuration.tsx
Normal 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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
|
@ -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>;
|
|
@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
|
@ -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");
|
||||||
|
}),
|
||||||
|
});
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,7 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
import { channelSchema } from "./channels-config";
|
|
||||||
|
|
||||||
export const setAndReplaceChannelsInputSchema = z.object({
|
|
||||||
channelSlug: z.string(),
|
|
||||||
config: channelSchema,
|
|
||||||
});
|
|
|
@ -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
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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;
|
|
||||||
}),
|
|
||||||
});
|
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 ?? []);
|
|
||||||
}),
|
|
||||||
});
|
|
|
@ -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
Loading…
Reference in a new issue