feat: tax code matcher (#564)

* feat:  add tax-classes-fetcher

* refactor: ♻️ add "byId" suffix to crud-settings & remove upsert

* feat:  add updateMany method to CrudSettingsManager

* feat:  add avatax & taxjar tax-code-map-repository

* refactor: 🚚 move to tax-code directory

* feat:  add getTaxCodes methods to provider clients

* refactor: ♻️ extract taxClass and taxCode schema

* refactor: 🚚 tax-code-map -> tax-code-match

* feat:  add taxjar-tax-code.service

* feat:  add avatax-tax-code.service

* feat:  add taxClass to graphql fragment

* feat:  use tax codes in calculate taxes

* fix: 🐛 undefined tax code bug & add tests

* build: 👷 add changeset

* Update avatax-tax-code-mapper.ts

* feat:  add routers & get rid of adapters & mappers

* refactor: ♻️ logger location -> name

* refactor: ♻️ clean up & rephrase logs

* refactor: ♻️ remove __typename from query

* docs: 💡 make comments about tax codes more informative

* refactor: ♻️ use resolveOptionalOrThrow on tax code description

* refactor: ♻️ rename tax-codes -> tax-classes, move and rename tax-code-schema

* refactor: 🚚 ctx -> authData

* refactor: 🚚 createUrqlClientFromCtx -> createUrqlClientFromAuthdata

* refactor: ♻️ dont spread ctx

* docs: 💡 add comment about fallback tax code

* refactor: ♻️ remove ..ctx

* fix: 🐛 use createGraphQLClient

* feat: tax code matcher ui (#658)

* feat:  use tax codes in calculate taxes

* feat:  add getTaxCodes methods to provider clients

* feat:  add matcher tables

* feat:  add log errors middleware

* fix: 🔊 fix misleading logs

* fix: 🐛 ctx appToken bug

* feat:  add Select override with wrapping label

* feat:  pre-select match

* docs: 💡 add comments about first connection

* docs: 💡 add comment about redirect

* refactor: 🔥 duplicate file

* feat:  add AppCard to tables

* feat:  add _error to breadcrumbs exceptions

* fix: 🐛 value not set on data

* feat: 🥅 add error for no channels configured

* fix: 🐛 replace update with upsert

* refactor: 🚚 channel-configuration-settings to repository

* fix: 🐛 updating a channel configuration

* fix: 🧪 fix wrong mock

* fix: 🐛 duplicating configs

* Update cool-turtles-reflect.md
This commit is contained in:
Adrian Pilarczyk 2023-06-20 17:53:27 +02:00 committed by GitHub
parent 59ff617282
commit 09e07995b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
81 changed files with 1847 additions and 509 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-taxes": minor
---
Added fetching tax codes from tax providers and storing the matched combinations of the provider tax codes/Saleor tax classes. The mapped tax codes are then used in the tax calculation process.

View file

@ -3,24 +3,24 @@ fragment TaxBaseLine on TaxableObjectLine {
__typename
... on CheckoutLine {
id
productVariant: variant {
checkoutProductVariant: variant {
id
product {
metafield(key: "taxjar_tax_code")
productType {
metafield(key: "taxjar_tax_code")
taxClass {
id
name
}
}
}
}
... on OrderLine {
id
variant {
orderProductVariant: variant {
id
product {
metafield(key: "taxjar_tax_code")
productType {
metafield(key: "taxjar_tax_code")
taxClass {
id
name
}
}
}

View file

@ -0,0 +1,28 @@
query TaxClassesList(
$before: String
$after: String
$first: Int
$last: Int
$filter: TaxClassFilterInput
$sortBy: TaxClassSortingInput
) {
taxClasses(
before: $before
after: $after
first: $first
last: $last
filter: $filter
sortBy: $sortBy
) {
edges {
node {
...TaxClass
}
}
}
}
fragment TaxClass on TaxClass {
id
name
}

View file

@ -12,7 +12,7 @@ export class TaxChannelsV1toV2MigrationManager {
private options: { mode: "report" | "migrate" } = { mode: "migrate" }
) {
this.logger = createLogger({
location: "TaxChannelsV1toV2MigrationManager",
name: "TaxChannelsV1toV2MigrationManager",
});
}

View file

@ -13,7 +13,7 @@ export class TaxProvidersV1toV2MigrationManager {
private options: { mode: "report" | "migrate" } = { mode: "migrate" }
) {
this.logger = createLogger({
location: "TaxProvidersV1toV2MigrationManager",
name: "TaxProvidersV1toV2MigrationManager",
});
}

View file

@ -0,0 +1,30 @@
import Avatax from "avatax";
import { createLogger, Logger } from "../../lib/logger";
import { FetchResult } from "avatax/lib/utils/fetch_result";
import { TaxCodeModel } from "avatax/lib/models/TaxCodeModel";
export class AvataxClientTaxCodeService {
// * These are the tax codes that we don't want to show to the user. For some reason, Avatax has them as active.
private readonly notSuitableKeys = ["Expired Tax Code - Do Not Use"];
private logger: Logger;
constructor(private client: Avatax) {
this.logger = createLogger({ name: "AvataxClientTaxCodeService" });
}
private filterOutInvalid(response: FetchResult<TaxCodeModel>) {
return response.value.filter((taxCode) => {
return (
taxCode.isActive &&
taxCode.description &&
!this.notSuitableKeys.includes(taxCode.description)
);
});
}
async getTaxCodes() {
// * If we want to do filtering on the front-end, we can use the `filter` parameter.
const result = await this.client.listTaxCodes({});
return this.filterOutInvalid(result);
}
}

View file

@ -6,6 +6,7 @@ import { AvataxConfig } from "./avatax-connection-schema";
import { CommitTransactionModel } from "avatax/lib/models/CommitTransactionModel";
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { AddressLocationInfo as AvataxAddress } from "avatax/lib/models/AddressLocationInfo";
import { AvataxClientTaxCodeService } from "./avatax-client-tax-code.service";
type AvataxSettings = {
appName: string;
@ -57,7 +58,7 @@ export class AvataxClient {
private logger: Logger;
constructor(config: AvataxConfig) {
this.logger = createLogger({ location: "AvataxClient" });
this.logger = createLogger({ name: "AvataxClient" });
const settings = createAvataxSettings(config);
const avataxClient = new Avatax(settings).withSecurity(config.credentials);
@ -75,4 +76,10 @@ export class AvataxClient {
async validateAddress({ address }: ValidateAddressArgs) {
return this.client.resolveAddress(address);
}
async getTaxCodes() {
const taxCodeService = new AvataxClientTaxCodeService(this.client);
return taxCodeService.getTaxCodes();
}
}

View file

@ -25,7 +25,6 @@ const postInputSchema = z.object({
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
next({
ctx: {
...ctx,
connectionService: new PublicAvataxConnectionService(
ctx.apiClient,
ctx.appId!,
@ -38,7 +37,7 @@ const protectedWithConfigurationService = protectedClientProcedure.use(({ next,
export const avataxConnectionRouter = router({
getById: protectedWithConfigurationService.input(getInputSchema).query(async ({ ctx, input }) => {
const logger = createLogger({
location: "avataxConnectionRouter.get",
name: "avataxConnectionRouter.get",
});
logger.debug("Route get called");

View file

@ -1,3 +1,4 @@
import { AuthData } from "@saleor/app-sdk/APL";
import {
OrderCreatedSubscriptionFragment,
OrderFulfilledSubscriptionFragment,
@ -16,9 +17,9 @@ export class AvataxWebhookService implements ProviderWebhookService {
client: AvataxClient;
private logger: Logger;
constructor(config: AvataxConfig) {
constructor(config: AvataxConfig, private authData: AuthData) {
this.logger = createLogger({
location: "AvataxWebhookService",
name: "AvataxWebhookService",
});
const avataxClient = new AvataxClient(config);
@ -27,7 +28,7 @@ export class AvataxWebhookService implements ProviderWebhookService {
}
async calculateTaxes(taxBase: TaxBaseFragment) {
const adapter = new AvataxCalculateTaxesAdapter(this.config);
const adapter = new AvataxCalculateTaxesAdapter(this.config, this.authData);
const response = await adapter.send({ taxBase });

View file

@ -1,10 +1,11 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { Logger, createLogger } from "../../../lib/logger";
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { AvataxClient, CreateTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxCalculateTaxesPayloadTransformer } from "./avatax-calculate-taxes-payload-transformer";
import { AvataxCalculateTaxesPayloadService } from "./avatax-calculate-taxes-payload.service";
import { AvataxCalculateTaxesResponseTransformer } from "./avatax-calculate-taxes-response-transformer";
export const SHIPPING_ITEM_CODE = "Shipping";
@ -20,29 +21,27 @@ export class AvataxCalculateTaxesAdapter
implements WebhookAdapter<AvataxCalculateTaxesPayload, AvataxCalculateTaxesResponse>
{
private logger: Logger;
constructor(private readonly config: AvataxConfig) {
this.logger = createLogger({ location: "AvataxCalculateTaxesAdapter" });
constructor(private readonly config: AvataxConfig, private authData: AuthData) {
this.logger = createLogger({ name: "AvataxCalculateTaxesAdapter" });
}
// todo: refactor because its getting too big
async send(payload: AvataxCalculateTaxesPayload): Promise<AvataxCalculateTaxesResponse> {
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
const payloadTransformer = new AvataxCalculateTaxesPayloadTransformer();
const target = payloadTransformer.transform({ ...payload, providerConfig: this.config });
this.logger.debug("Transforming the Saleor payload for calculating taxes with Avatax...");
const payloadService = new AvataxCalculateTaxesPayloadService(this.authData);
const target = await payloadService.getPayload(payload.taxBase, this.config);
this.logger.debug(
{ transformedPayload: target },
"Will call Avatax createTransaction with transformed payload:"
);
this.logger.debug("Calling Avatax createTransaction with transformed payload...");
const client = new AvataxClient(this.config);
const response = await client.createTransaction(target);
this.logger.debug({ response }, "Avatax createTransaction responded with:");
this.logger.debug("Avatax createTransaction successfully responded");
const responseTransformer = new AvataxCalculateTaxesResponseTransformer();
const transformedResponse = responseTransformer.transform(response);
this.logger.debug({ transformedResponse }, "Transformed Avatax createTransaction response to:");
this.logger.debug("Transformed Avatax createTransaction response");
return transformedResponse;
}

View file

@ -13,6 +13,7 @@ import { BoundaryLevel } from "avatax/lib/enums/BoundaryLevel";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxConfigMockGenerator } from "../avatax-config-mock-generator";
import { ChannelConfigMockGenerator } from "../../channel-configuration/channel-config-mock-generator";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
type TaxBase = TaxBaseFragment;
@ -41,12 +42,12 @@ const defaultTaxBase: TaxBase = {
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjNmMjYwZmMyLTZjN2UtNGM5Ni1iYTMwLTEyMjAyODMzOTUyZA==",
variant: {
orderProductVariant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ5",
product: {
metafield: null,
productType: {
metafield: null,
taxClass: {
id: "VGF4Q2xhc3M6MjI=",
name: "Clothing",
},
},
},
@ -63,12 +64,12 @@ const defaultTaxBase: TaxBase = {
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjNlNGZjODdkLTIyMmEtNDZiYi1iYzIzLWJiYWVkODVlOTQ4Mg==",
variant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzUw",
orderProductVariant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ6",
product: {
metafield: null,
productType: {
metafield: null,
taxClass: {
id: "VGF4Q2xhc3M7MjI=",
name: "Shoes",
},
},
},
@ -85,12 +86,12 @@ const defaultTaxBase: TaxBase = {
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOmM2NTBhMzVkLWQ1YjQtNGRhNy1hMjNjLWEzODU4ZDE1MzI2Mw==",
variant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQw",
orderProductVariant: {
id: "UHJvZHFjdFZhcmlhbnQ6MzQ5",
product: {
metafield: null,
productType: {
metafield: null,
taxClass: {
id: "VGF4Q2xhc3M6TWjI=",
name: "Sweets",
},
},
},
@ -933,10 +934,21 @@ const defaultTransactionModel: TransactionModel = {
],
};
const defaultTaxCodeMatches: AvataxTaxCodeMatches = [
{
data: {
avataxTaxCode: "P0000000",
saleorTaxClassId: "VGF4Q2xhc3M6MjI=",
},
id: "VGF4Q29kZTox",
},
];
const testingScenariosMap = {
default: {
taxBase: defaultTaxBase,
response: defaultTransactionModel,
matches: defaultTaxCodeMatches,
},
};
@ -967,4 +979,7 @@ export class AvataxCalculateTaxesMockGenerator {
...testingScenariosMap[this.scenario].response,
...overrides,
});
generateTaxCodeMatches = (overrides: AvataxTaxCodeMatches = []): AvataxTaxCodeMatches =>
structuredClone([...testingScenariosMap[this.scenario].matches, ...overrides]);
}

View file

@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { AvataxCalculateTaxesMockGenerator } from "./avatax-calculate-taxes-mock-generator";
import { AvataxCalculateTaxesPayloadLinesTransformer } from "./avatax-calculate-taxes-payload-lines-transformer";
const transformer = new AvataxCalculateTaxesPayloadLinesTransformer();
describe("AvataxCalculateTaxesPayloadLinesTransformer", () => {
describe("transform", () => {
it("maps lines, adds shipping as line and maps the tax code of one product", () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const taxBaseMock = mockGenerator.generateTaxBase();
const matchesMock = mockGenerator.generateTaxCodeMatches();
const lines = transformer.transform(taxBaseMock, avataxConfigMock, matchesMock);
expect(lines).toEqual([
{
amount: 60,
quantity: 3,
taxCode: "P0000000",
taxIncluded: true,
discounted: false,
},
{
amount: 20,
quantity: 1,
taxCode: "",
taxIncluded: true,
discounted: false,
},
{
amount: 100,
quantity: 2,
taxCode: "",
taxIncluded: true,
discounted: false,
},
{
amount: 48.33,
itemCode: "Shipping",
quantity: 1,
taxCode: "FR000000",
taxIncluded: true,
discounted: false,
},
]);
});
it("when no shipping in tax base, does not add shipping as line", () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const matchesMock = mockGenerator.generateTaxCodeMatches();
const taxBaseMock = mockGenerator.generateTaxBase({ shippingPrice: { amount: 0 } });
const lines = transformer.transform(taxBaseMock, avataxConfigMock, matchesMock);
expect(lines).toEqual([
{
amount: 60,
quantity: 3,
taxCode: "P0000000",
taxIncluded: true,
discounted: false,
},
{
amount: 20,
quantity: 1,
taxCode: "",
taxIncluded: true,
discounted: false,
},
{
amount: 100,
quantity: 2,
taxCode: "",
taxIncluded: true,
discounted: false,
},
]);
});
it("when discounts, sets discounted to true", () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const matchesMock = mockGenerator.generateTaxCodeMatches();
const taxBaseMock = mockGenerator.generateTaxBase({
discounts: [{ amount: { amount: 10 } }],
});
const lines = transformer.transform(taxBaseMock, avataxConfigMock, matchesMock);
expect(lines).toEqual([
{
amount: 60,
quantity: 3,
taxCode: "P0000000",
taxIncluded: true,
discounted: true,
},
{
amount: 20,
quantity: 1,
taxCode: "",
taxIncluded: true,
discounted: true,
},
{
amount: 100,
quantity: 2,
taxCode: "",
taxIncluded: true,
discounted: true,
},
{
amount: 48.33,
discounted: true,
itemCode: "Shipping",
quantity: 1,
taxCode: "FR000000",
taxIncluded: true,
},
]);
});
});
});

View file

@ -0,0 +1,44 @@
import { LineItemModel } from "avatax/lib/models/LineItemModel";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
import { AvataxTaxCodeMatcher } from "../tax-code/avatax-tax-code-matcher";
import { SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter";
export class AvataxCalculateTaxesPayloadLinesTransformer {
transform(
taxBase: TaxBaseFragment,
config: AvataxConfig,
matches: AvataxTaxCodeMatches
): LineItemModel[] {
const isDiscounted = taxBase.discounts.length > 0;
const productLines: LineItemModel[] = taxBase.lines.map((line) => {
const matcher = new AvataxTaxCodeMatcher();
const taxCode = matcher.match(line, matches);
return {
amount: line.totalPrice.amount,
taxIncluded: taxBase.pricesEnteredWithTax,
taxCode,
quantity: line.quantity,
discounted: isDiscounted,
};
});
if (taxBase.shippingPrice.amount !== 0) {
// * In Avatax, shipping is a regular line
const shippingLine: LineItemModel = {
amount: taxBase.shippingPrice.amount,
itemCode: SHIPPING_ITEM_CODE,
taxCode: config.shippingTaxCode,
quantity: 1,
taxIncluded: taxBase.pricesEnteredWithTax,
discounted: isDiscounted,
};
return [...productLines, shippingLine];
}
return productLines;
}
}

View file

@ -1,20 +1,19 @@
import { describe, expect, it } from "vitest";
import { AvataxCalculateTaxesMockGenerator } from "./avatax-calculate-taxes-mock-generator";
import {
AvataxCalculateTaxesPayloadTransformer,
mapPayloadLines,
} from "./avatax-calculate-taxes-payload-transformer";
import { AvataxCalculateTaxesPayloadTransformer } from "./avatax-calculate-taxes-payload-transformer";
describe("AvataxCalculateTaxesPayloadTransformer", () => {
it("when discounts, calculates the sum of discounts", () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const taxBaseMock = mockGenerator.generateTaxBase({ discounts: [{ amount: { amount: 10 } }] });
const matchesMock = mockGenerator.generateTaxCodeMatches();
const payload = new AvataxCalculateTaxesPayloadTransformer().transform({
taxBase: taxBaseMock,
providerConfig: avataxConfigMock,
});
const payload = new AvataxCalculateTaxesPayloadTransformer().transform(
taxBaseMock,
avataxConfigMock,
matchesMock
);
expect(payload.model.discount).toEqual(10);
});
@ -22,123 +21,14 @@ describe("AvataxCalculateTaxesPayloadTransformer", () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const taxBaseMock = mockGenerator.generateTaxBase();
const matchesMock = mockGenerator.generateTaxCodeMatches();
const payload = new AvataxCalculateTaxesPayloadTransformer().transform({
taxBase: taxBaseMock,
providerConfig: avataxConfigMock,
});
const payload = new AvataxCalculateTaxesPayloadTransformer().transform(
taxBaseMock,
avataxConfigMock,
matchesMock
);
expect(payload.model.discount).toEqual(0);
});
});
describe("mapPayloadLines", () => {
it("map lines and adds shipping as line", () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const taxBaseMock = mockGenerator.generateTaxBase();
const lines = mapPayloadLines(taxBaseMock, avataxConfigMock);
expect(lines).toEqual([
{
amount: 60,
quantity: 3,
taxCode: "",
taxIncluded: true,
discounted: false,
},
{
amount: 20,
quantity: 1,
taxCode: "",
taxIncluded: true,
discounted: false,
},
{
amount: 100,
quantity: 2,
taxCode: "",
taxIncluded: true,
discounted: false,
},
{
amount: 48.33,
itemCode: "Shipping",
quantity: 1,
taxCode: "FR000000",
taxIncluded: true,
discounted: false,
},
]);
});
it("when no shipping in tax base, does not add shipping as line", () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const taxBaseMock = mockGenerator.generateTaxBase({ shippingPrice: { amount: 0 } });
const lines = mapPayloadLines(taxBaseMock, avataxConfigMock);
expect(lines).toEqual([
{
amount: 60,
quantity: 3,
taxCode: "",
taxIncluded: true,
discounted: false,
},
{
amount: 20,
quantity: 1,
taxCode: "",
taxIncluded: true,
discounted: false,
},
{
amount: 100,
quantity: 2,
taxCode: "",
taxIncluded: true,
discounted: false,
},
]);
});
it("when discounts, sets discounted to true", () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const taxBaseMock = mockGenerator.generateTaxBase({ discounts: [{ amount: { amount: 10 } }] });
const lines = mapPayloadLines(taxBaseMock, avataxConfigMock);
expect(lines).toEqual([
{
amount: 60,
quantity: 3,
taxCode: "",
taxIncluded: true,
discounted: true,
},
{
amount: 20,
quantity: 1,
taxCode: "",
taxIncluded: true,
discounted: true,
},
{
amount: 100,
quantity: 2,
taxCode: "",
taxIncluded: true,
discounted: true,
},
{
amount: 48.33,
discounted: true,
itemCode: "Shipping",
quantity: 1,
taxCode: "FR000000",
taxIncluded: true,
},
]);
});
});

View file

@ -1,61 +1,33 @@
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { LineItemModel } from "avatax/lib/models/LineItemModel";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { discountUtils } from "../../taxes/discount-utils";
import { avataxAddressFactory } from "../address-factory";
import { CreateTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-connection-schema";
import { SHIPPING_ITEM_CODE } from "./avatax-calculate-taxes-adapter";
export function mapPayloadLines(taxBase: TaxBaseFragment, config: AvataxConfig): LineItemModel[] {
const isDiscounted = taxBase.discounts.length > 0;
const productLines: LineItemModel[] = taxBase.lines.map((line) => ({
amount: line.totalPrice.amount,
taxIncluded: taxBase.pricesEnteredWithTax,
// todo: get from tax code matcher
taxCode: "",
quantity: line.quantity,
discounted: isDiscounted,
}));
if (taxBase.shippingPrice.amount !== 0) {
// * In Avatax, shipping is a regular line
const shippingLine: LineItemModel = {
amount: taxBase.shippingPrice.amount,
itemCode: SHIPPING_ITEM_CODE,
taxCode: config.shippingTaxCode,
quantity: 1,
taxIncluded: taxBase.pricesEnteredWithTax,
discounted: isDiscounted,
};
return [...productLines, shippingLine];
}
return productLines;
}
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
import { AvataxCalculateTaxesPayloadLinesTransformer } from "./avatax-calculate-taxes-payload-lines-transformer";
export class AvataxCalculateTaxesPayloadTransformer {
transform({
taxBase,
providerConfig,
}: {
taxBase: TaxBaseFragment;
providerConfig: AvataxConfig;
}): CreateTransactionArgs {
transform(
taxBase: TaxBaseFragment,
avataxConfig: AvataxConfig,
matches: AvataxTaxCodeMatches
): CreateTransactionArgs {
const payloadLinesTransformer = new AvataxCalculateTaxesPayloadLinesTransformer();
return {
model: {
type: DocumentType.SalesOrder,
customerCode: taxBase.sourceObject.user?.id ?? "",
companyCode: providerConfig.companyCode,
companyCode: avataxConfig.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: providerConfig.isAutocommit,
commit: avataxConfig.isAutocommit,
addresses: {
shipFrom: avataxAddressFactory.fromChannelAddress(providerConfig.address),
shipFrom: avataxAddressFactory.fromChannelAddress(avataxConfig.address),
shipTo: avataxAddressFactory.fromSaleorAddress(taxBase.address!),
},
currencyCode: taxBase.currency,
lines: mapPayloadLines(taxBase, providerConfig),
lines: payloadLinesTransformer.transform(taxBase, avataxConfig, matches),
date: new Date(),
discount: discountUtils.sumDiscounts(
taxBase.discounts.map((discount) => discount.amount.amount)

View file

@ -0,0 +1,26 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { CreateTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxTaxCodeMatchesService } from "../tax-code/avatax-tax-code-matches.service";
import { AvataxCalculateTaxesPayloadTransformer } from "./avatax-calculate-taxes-payload-transformer";
export class AvataxCalculateTaxesPayloadService {
constructor(private authData: AuthData) {}
private getMatches() {
const taxCodeMatchesService = new AvataxTaxCodeMatchesService(this.authData);
return taxCodeMatchesService.getAll();
}
async getPayload(
taxBase: TaxBaseFragment,
avataxConfig: AvataxConfig
): Promise<CreateTransactionArgs> {
const matches = await this.getMatches();
const payloadTransformer = new AvataxCalculateTaxesPayloadTransformer();
return payloadTransformer.transform(taxBase, avataxConfig, matches);
}
}

View file

@ -25,7 +25,7 @@ export class AvataxConnectionRepository {
TAX_PROVIDER_KEY
);
this.logger = createLogger({
location: "AvataxConnectionRepository",
name: "AvataxConnectionRepository",
metadataKey: TAX_PROVIDER_KEY,
});
}
@ -67,7 +67,7 @@ export class AvataxConnectionRepository {
}
async get(id: string): Promise<AvataxConnection> {
const { data } = await this.crudSettingsManager.read(id);
const { data } = await this.crudSettingsManager.readById(id);
const connection = getSchema.parse(data);
@ -84,7 +84,7 @@ export class AvataxConnectionRepository {
}
async patch(id: string, input: Pick<AvataxConnection, "config">): Promise<void> {
return this.crudSettingsManager.update(id, input);
return this.crudSettingsManager.updateById(id, input);
}
async delete(id: string): Promise<void> {

View file

@ -11,7 +11,7 @@ export class AvataxConnectionService {
private avataxConnectionRepository: AvataxConnectionRepository;
constructor(client: Client, appId: string, saleorApiUrl: string) {
this.logger = createLogger({
location: "AvataxConnectionService",
name: "AvataxConnectionService",
});
const settingsManager = createSettingsManager(client, appId);

View file

@ -11,7 +11,7 @@ export class AvataxValidationService {
constructor() {
this.logger = createLogger({
location: "AvataxValidationService",
name: "AvataxValidationService",
});
}

View file

@ -18,29 +18,26 @@ export class AvataxOrderCreatedAdapter
private logger: Logger;
constructor(private readonly config: AvataxConfig) {
this.logger = createLogger({ location: "AvataxOrderCreatedAdapter" });
this.logger = createLogger({ name: "AvataxOrderCreatedAdapter" });
}
async send(payload: AvataxOrderCreatedPayload): Promise<AvataxOrderCreatedResponse> {
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
this.logger.debug("Transforming the Saleor payload for creating order with Avatax...");
const payloadTransformer = new AvataxOrderCreatedPayloadTransformer(this.config);
const target = payloadTransformer.transform(payload);
this.logger.debug(
{ transformedPayload: target },
"Will call Avatax createTransaction with transformed payload:"
);
this.logger.debug("Calling Avatax createTransaction with transformed payload...");
const client = new AvataxClient(this.config);
const response = await client.createTransaction(target);
this.logger.debug({ response }, "Avatax createTransaction responded with:");
this.logger.debug("Avatax createTransaction successfully responded");
const responseTransformer = new AvataxOrderCreatedResponseTransformer();
const transformedResponse = responseTransformer.transform(response);
this.logger.debug({ transformedResponse }, "Transformed Avatax createTransaction response to:");
this.logger.debug("Transformed Avatax createTransaction response");
return transformedResponse;
}

View file

@ -18,29 +18,26 @@ export class AvataxOrderFulfilledAdapter
private logger: Logger;
constructor(private readonly config: AvataxConfig) {
this.logger = createLogger({ location: "AvataxOrderFulfilledAdapter" });
this.logger = createLogger({ name: "AvataxOrderFulfilledAdapter" });
}
async send(payload: AvataxOrderFulfilledPayload): Promise<AvataxOrderFulfilledResponse> {
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
this.logger.debug("Transforming the Saleor payload for commiting transaction with Avatax...");
const payloadTransformer = new AvataxOrderFulfilledPayloadTransformer(this.config);
const target = payloadTransformer.transform({ ...payload });
this.logger.debug(
{ transformedPayload: target },
"Will call Avatax commitTransaction with transformed payload:"
);
this.logger.debug("Calling Avatax commitTransaction with transformed payload...");
const client = new AvataxClient(this.config);
const response = await client.commitTransaction(target);
this.logger.debug({ response }, "Avatax commitTransaction responded with:");
this.logger.debug("Avatax commitTransaction succesfully responded");
const responseTransformer = new AvataxOrderFulfilledResponseTransformer();
const transformedResponse = responseTransformer.transform(response);
this.logger.debug({ transformedResponse }, "Transformed Avatax commitTransaction response to:");
this.logger.debug("Transformed Avatax commitTransaction response");
return transformedResponse;
}

View file

@ -0,0 +1,48 @@
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
import { z } from "zod";
import { Logger, createLogger } from "../../../lib/logger";
import { CrudSettingsManager } from "../../crud-settings/crud-settings.service";
export const avataxTaxCodeMatchSchema = z.object({
saleorTaxClassId: z.string(),
avataxTaxCode: z.string(),
});
export type AvataxTaxCodeMatch = z.infer<typeof avataxTaxCodeMatchSchema>;
const avataxTaxCodeMatchesSchema = z.array(
z.object({
id: z.string(),
data: avataxTaxCodeMatchSchema,
})
);
export type AvataxTaxCodeMatches = z.infer<typeof avataxTaxCodeMatchesSchema>;
const metadataKey = "avatax-tax-code-map";
export class AvataxTaxCodeMatchRepository {
private crudSettingsManager: CrudSettingsManager;
private logger: Logger;
constructor(settingsManager: EncryptedMetadataManager, saleorApiUrl: string) {
this.crudSettingsManager = new CrudSettingsManager(settingsManager, saleorApiUrl, metadataKey);
this.logger = createLogger({
name: "AvataxTaxCodeMatchRepository",
metadataKey,
});
}
async getAll(): Promise<AvataxTaxCodeMatches> {
const { data } = await this.crudSettingsManager.readAll();
return avataxTaxCodeMatchesSchema.parse(data);
}
async create(input: AvataxTaxCodeMatch): Promise<{ data: { id: string } }> {
return this.crudSettingsManager.create({ data: input });
}
async updateById(id: string, input: AvataxTaxCodeMatch): Promise<void> {
return this.crudSettingsManager.updateById(id, { data: input });
}
}

View file

@ -0,0 +1,76 @@
import { TaxBaseLineFragment } from "../../../../generated/graphql";
import { AvataxTaxCodeMatches } from "./avatax-tax-code-match-repository";
import { AvataxTaxCodeMatcher } from "./avatax-tax-code-matcher";
import { describe, expect, it } from "vitest";
const matcher = new AvataxTaxCodeMatcher();
describe("AvataxTaxCodeMatcher", () => {
it("returns empty string when tax class is not found", () => {
const line: TaxBaseLineFragment = {
quantity: 1,
totalPrice: {
amount: 1,
},
unitPrice: {
amount: 1,
},
sourceLine: {
id: "1",
__typename: "OrderLine",
orderProductVariant: {
id: "1",
product: {},
},
},
};
const matches: AvataxTaxCodeMatches = [
{
data: {
saleorTaxClassId: "",
avataxTaxCode: "1",
},
id: "1",
},
];
expect(matcher.match(line, matches)).toEqual("");
});
it("returns a match when tax class is found", () => {
const line: TaxBaseLineFragment = {
quantity: 1,
totalPrice: {
amount: 1,
},
unitPrice: {
amount: 1,
},
sourceLine: {
id: "1",
__typename: "OrderLine",
orderProductVariant: {
id: "1",
product: {
taxClass: {
name: "Clothing",
id: "1",
},
},
},
},
};
const matches: AvataxTaxCodeMatches = [
{
data: {
saleorTaxClassId: "1",
avataxTaxCode: "123412",
},
id: "1",
},
];
const taxCode = matcher.match(line, matches);
expect(taxCode).toEqual("123412");
});
});

View file

@ -0,0 +1,27 @@
import { TaxBaseLineFragment } from "../../../../generated/graphql";
import { AvataxTaxCodeMatches } from "./avatax-tax-code-match-repository";
export class AvataxTaxCodeMatcher {
private mapTaxClassWithTaxMatch(taxClassId: string, matches: AvataxTaxCodeMatches) {
return matches.find((m) => m.data.saleorTaxClassId === taxClassId);
}
private getTaxClassId(line: TaxBaseLineFragment): string | undefined {
if (line.sourceLine.__typename === "CheckoutLine") {
return line.sourceLine.checkoutProductVariant.product.taxClass?.id;
}
if (line.sourceLine.__typename === "OrderLine") {
return line.sourceLine.orderProductVariant?.product.taxClass?.id;
}
}
match(line: TaxBaseLineFragment, matches: AvataxTaxCodeMatches) {
const taxClassId = this.getTaxClassId(line);
// We can fall back to empty string if we don't have a tax code match
return taxClassId
? this.mapTaxClassWithTaxMatch(taxClassId, matches)?.data.avataxTaxCode ?? ""
: "";
}
}

View file

@ -0,0 +1,40 @@
import { createLogger } from "../../../lib/logger";
import { AvataxTaxCodeMatchesService } from "./avatax-tax-code-matches.service";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { router } from "../../trpc/trpc-server";
import { avataxTaxCodeMatchSchema } from "./avatax-tax-code-match-repository";
const protectedWithAvataxTaxCodeMatchesService = protectedClientProcedure.use(({ next, ctx }) =>
next({
ctx: {
taxCodeMatchesService: new AvataxTaxCodeMatchesService({
saleorApiUrl: ctx.saleorApiUrl,
token: ctx.appToken!,
appId: ctx.appId!,
}),
},
})
);
export const avataxTaxCodeMatchesRouter = router({
getAll: protectedWithAvataxTaxCodeMatchesService.query(async ({ ctx }) => {
const logger = createLogger({
name: "avataxTaxCodeMatchesRouter.fetch",
});
logger.info("Returning tax code matches");
return ctx.taxCodeMatchesService.getAll();
}),
upsert: protectedWithAvataxTaxCodeMatchesService
.input(avataxTaxCodeMatchSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({
name: "avataxTaxCodeMatchesRouter.upsert",
});
logger.info("Upserting tax code match");
return ctx.taxCodeMatchesService.upsert(input);
}),
});

View file

@ -0,0 +1,43 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { Logger, createLogger } from "../../../lib/logger";
import { createSettingsManager } from "../../app/metadata-manager";
import {
AvataxTaxCodeMatch,
AvataxTaxCodeMatchRepository,
AvataxTaxCodeMatches,
} from "./avatax-tax-code-match-repository";
import { createGraphQLClient } from "@saleor/apps-shared";
export class AvataxTaxCodeMatchesService {
private logger: Logger;
private taxCodeMatchRepository: AvataxTaxCodeMatchRepository;
constructor(authData: AuthData) {
this.logger = createLogger({ name: "AvataxTaxCodeMatchesService" });
const client = createGraphQLClient({
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
});
const { appId, saleorApiUrl } = authData;
const settingsManager = createSettingsManager(client, appId);
this.taxCodeMatchRepository = new AvataxTaxCodeMatchRepository(settingsManager, saleorApiUrl);
}
async getAll(): Promise<AvataxTaxCodeMatches> {
return this.taxCodeMatchRepository.getAll();
}
async upsert(input: AvataxTaxCodeMatch): Promise<void | { data: { id: string } }> {
const taxCodeMatches = await this.getAll();
const taxCodeMatch = taxCodeMatches.find(
(match) => match.data.saleorTaxClassId === input.saleorTaxClassId
);
if (taxCodeMatch) {
return this.taxCodeMatchRepository.updateById(taxCodeMatch.id, input);
}
return this.taxCodeMatchRepository.create(input);
}
}

View file

@ -0,0 +1,30 @@
import { router } from "../../trpc/trpc-server";
import { z } from "zod";
import { createLogger } from "../../../lib/logger";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { AvataxConnectionService } from "../configuration/avatax-connection.service";
import { AvataxTaxCodesService } from "./avatax-tax-codes.service";
const getAllForIdSchema = z.object({ connectionId: z.string() });
export const avataxTaxCodesRouter = router({
getAllForId: protectedClientProcedure.input(getAllForIdSchema).query(async ({ ctx, input }) => {
const logger = createLogger({
name: "avataxTaxCodesRouter.getAllForId",
});
const connectionService = new AvataxConnectionService(
ctx.apiClient,
ctx.appId!,
ctx.saleorApiUrl
);
const connection = await connectionService.getById(input.connectionId);
const taxCodesService = new AvataxTaxCodesService(connection.config);
logger.debug("Returning tax codes");
return taxCodesService.getAll();
}),
});

View file

@ -0,0 +1,27 @@
import { AvataxClient } from "../avatax-client";
import { AvataxConfig } from "../avatax-connection-schema";
import type { TaxCode } from "../../taxes/tax-code";
import { FetchResult } from "avatax/lib/utils/fetch_result";
import { TaxCodeModel } from "avatax/lib/models/TaxCodeModel";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
export class AvataxTaxCodesService {
private client: AvataxClient;
constructor(config: AvataxConfig) {
this.client = new AvataxClient(config);
}
private adapt(taxCodes: TaxCodeModel[]): TaxCode[] {
return taxCodes.map((item) => ({
description: taxProviderUtils.resolveOptionalOrThrow(item.description),
code: item.taxCode,
}));
}
async getAll() {
const response = await this.client.getTaxCodes();
return this.adapt(response);
}
}

View file

@ -0,0 +1,116 @@
import { useDashboardNotification } from "@saleor/apps-shared";
import React from "react";
import { trpcClient } from "../../trpc/trpc-client";
import { Table } from "../../ui/table";
import { Select } from "../../ui/_select";
import { Box, Text } from "@saleor/macaw-ui/next";
import { AppCard } from "../../ui/app-card";
const SelectTaxCode = ({ taxClassId }: { taxClassId: string }) => {
const [value, setValue] = React.useState("");
const { notifySuccess, notifyError } = useDashboardNotification();
const { data: avataxMatches, isLoading: isMatchesLoading } =
trpcClient.avataxMatches.getAll.useQuery();
React.useEffect(() => {
if (avataxMatches) {
const match = avataxMatches?.find((item) => item.data.saleorTaxClassId === taxClassId);
if (match) {
setValue(match.data.avataxTaxCode);
}
}
}, [avataxMatches, taxClassId]);
const { mutate: updateMutation } = trpcClient.avataxMatches.upsert.useMutation({
onSuccess() {
notifySuccess("Success", "Updated Avatax tax code matches");
},
onError(error) {
notifyError("Error", error.message);
},
});
const { data: providers } = trpcClient.providersConfiguration.getAll.useQuery();
/*
* Tax Code Matcher is only available when there is at least one connection.
* The reason for it is that we need any working credentials to fetch the provider tax codes.
*/
const firstConnectionId = providers?.[0].id;
const { data: taxCodes = [], isLoading: isCodesLoading } =
trpcClient.avataxTaxCodes.getAllForId.useQuery(
{
connectionId: firstConnectionId!,
},
{
enabled: firstConnectionId !== undefined,
}
);
const changeValue = (avataxTaxCode: string) => {
setValue(avataxTaxCode);
updateMutation({
saleorTaxClassId: taxClassId,
avataxTaxCode,
});
};
const isLoading = isMatchesLoading || isCodesLoading;
return (
<Select
value={value ?? ""}
disabled={isLoading}
onChange={(value) => changeValue(String(value))}
options={[
...(isLoading
? [{ value: "", label: "Loading..." }]
: [{ value: "", label: "Not assigned" }]),
...taxCodes.map((item) => ({
value: item.code,
label: `${item.code} | ${item.description}`,
})),
]}
/>
);
};
export const AvataxTaxCodeMatcherTable = () => {
const { data: taxClasses = [], isLoading } = trpcClient.taxClasses.getAll.useQuery();
if (isLoading) {
return (
<Box>
<Text color="textNeutralSubdued">Loading...</Text>
</Box>
);
}
return (
<AppCard>
<Table.Container>
<Table.THead>
<Table.TR>
<Table.TH>Saleor tax class</Table.TH>
<Table.TH>Avatax tax code</Table.TH>
</Table.TR>
</Table.THead>
<Table.TBody>
{taxClasses.map((taxClass) => {
return (
<Table.TR key={taxClass.id}>
<Table.TD>{taxClass.name}</Table.TD>
<Table.TD>
<SelectTaxCode taxClassId={taxClass.id} />
</Table.TD>
</Table.TR>
);
})}
</Table.TBody>
</Table.Container>
</AppCard>
);
};

View file

@ -1,7 +1,7 @@
import { z } from "zod";
export const channelConfigPropertiesSchema = z.object({
providerConnectionId: z.string().or(z.null()),
providerConnectionId: z.string().nonempty().or(z.null()),
slug: z.string(),
});

View file

@ -16,7 +16,7 @@ describe("ChannelConfigurationMerger", () => {
expect(result).toEqual([
{
id: "1",
id: expect.any(String),
config: {
providerConnectionId: "aa5293e5-7f5d-4782-a619-222ead918e50",
slug: "default-channel",
@ -37,7 +37,7 @@ describe("ChannelConfigurationMerger", () => {
expect(result).toEqual([
{
id: "1",
id: expect.any(String),
config: {
providerConnectionId: null,
slug: "default-channel",

View file

@ -1,4 +1,5 @@
import { ChannelFragment } from "../../../generated/graphql";
import { createId } from "../../lib/utils";
import { ChannelsConfig } from "./channel-config";
export class ChannelConfigurationMerger {
@ -6,10 +7,20 @@ export class ChannelConfigurationMerger {
return channels.map((channel) => {
const channelConfig = channelsConfig.find((c) => c.config.slug === channel.slug);
if (!channelConfig) {
return {
id: channel.id,
id: createId(),
config: {
providerConnectionId: channelConfig?.config.providerConnectionId ?? null,
providerConnectionId: null,
slug: channel.slug,
},
};
}
return {
id: channelConfig.id,
config: {
providerConnectionId: channelConfig.config.providerConnectionId,
slug: channel.slug,
},
};

View file

@ -1,19 +1,19 @@
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";
import { ChannelConfig, channelsSchema } from "./channel-config";
export class ChannelConfigurationSettings {
export class ChannelConfigurationRepository {
private crudSettingsManager: CrudSettingsManager;
private logger: Logger;
constructor(private settingsManager: EncryptedMetadataManager, saleorApiUrl: string) {
constructor(settingsManager: EncryptedMetadataManager, saleorApiUrl: string) {
this.crudSettingsManager = new CrudSettingsManager(
settingsManager,
saleorApiUrl,
"channel-configuration"
);
this.logger = createLogger({
location: "ChannelConfigurationSettings",
name: "ChannelConfigurationRepository",
});
}
@ -23,7 +23,11 @@ export class ChannelConfigurationSettings {
return channelsSchema.parse(data);
}
async upsert(id: string, data: ChannelConfigProperties) {
await this.crudSettingsManager.upsert(id, { config: data });
async updateById(id: string, input: Pick<ChannelConfig, "config">) {
return this.crudSettingsManager.updateById(id, input);
}
async create(input: Pick<ChannelConfig, "config">) {
return this.crudSettingsManager.create(input);
}
}

View file

@ -1,13 +1,12 @@
import { createLogger } from "../../lib/logger";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server";
import { channelConfigSchema } from "./channel-config";
import { channelConfigPropertiesSchema } 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!,
@ -18,9 +17,9 @@ const protectedWithConfigurationService = protectedClientProcedure.use(({ next,
);
export const channelsConfigurationRouter = router({
fetch: protectedWithConfigurationService.query(async ({ ctx, input }) => {
getAll: protectedWithConfigurationService.query(async ({ ctx }) => {
const logger = createLogger({
location: "channelsConfigurationRouter.fetch",
name: "channelsConfigurationRouter.fetch",
});
const channelConfiguration = ctx.connectionService;
@ -30,17 +29,17 @@ export const channelsConfigurationRouter = router({
return channelConfiguration.getAll();
}),
upsert: protectedWithConfigurationService
.input(channelConfigSchema)
.input(channelConfigPropertiesSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({
saleorApiUrl: ctx.saleorApiUrl,
procedure: "channelsConfigurationRouter.upsert",
});
const configurationService = ctx.connectionService;
const result = await ctx.connectionService.upsert(input);
await configurationService.update(input.id, input.config);
logger.info("Channel configuration upserted");
logger.info("Channel configuration updated");
return result;
}),
});

View file

@ -1,6 +1,6 @@
import { Client } from "urql";
import { ChannelConfigProperties } from "./channel-config";
import { ChannelConfigurationSettings } from "./channel-configuration-settings";
import { ChannelConfigurationRepository } from "./channel-configuration-repository";
import { ChannelsFetcher } from "./channel-fetcher";
import { ChannelConfigurationMerger } from "./channel-configuration-merger";
import { TaxChannelsV1toV2MigrationManager } from "../../../scripts/migrations/tax-channels-migration-v1-to-v2";
@ -9,7 +9,7 @@ import { Logger, createLogger } from "../../lib/logger";
import { createSettingsManager } from "../app/metadata-manager";
export class ChannelConfigurationService {
private configurationService: ChannelConfigurationSettings;
private configurationRepository: ChannelConfigurationRepository;
private logger: Logger;
private settingsManager: EncryptedMetadataManager;
constructor(private client: Client, private appId: string, private saleorApiUrl: string) {
@ -18,10 +18,13 @@ export class ChannelConfigurationService {
this.settingsManager = settingsManager;
this.logger = createLogger({
location: "ChannelConfigurationService",
name: "ChannelConfigurationService",
});
this.configurationService = new ChannelConfigurationSettings(settingsManager, saleorApiUrl);
this.configurationRepository = new ChannelConfigurationRepository(
settingsManager,
saleorApiUrl
);
}
async getAll() {
@ -42,14 +45,23 @@ export class ChannelConfigurationService {
this.logger.info("Config is up to date, no need to migrate.");
const channels = await channelsFetcher.fetchChannels();
const channelConfiguration = await this.configurationService.getAll();
const channelConfiguration = await this.configurationRepository.getAll();
const configurationMerger = new ChannelConfigurationMerger();
return configurationMerger.merge(channels, channelConfiguration);
}
async update(id: string, data: ChannelConfigProperties) {
await this.configurationService.upsert(id, data);
async upsert(data: ChannelConfigProperties) {
const { slug } = data;
const configurations = await this.configurationRepository.getAll();
const existingConfiguration = configurations.find((c) => c.config.slug === slug);
if (existingConfiguration) {
return this.configurationRepository.updateById(existingConfiguration.id, { config: data });
}
return this.configurationRepository.create({ config: data });
}
}

View file

@ -36,7 +36,7 @@ const Skeleton = () => {
};
export const ChannelList = () => {
const { data = [], isFetching, isFetched } = trpcClient.channelsConfiguration.fetch.useQuery();
const { data = [], isFetching, isFetched } = trpcClient.channelsConfiguration.getAll.useQuery();
const isAnyChannelConfigured = data.length > 0;
const isResult = isFetched && isAnyChannelConfigured;

View file

@ -1,15 +1,8 @@
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";
import { AppDashboardLink } from "../../ui/app-dashboard-link";
const Intro = () => {
const appBridge = useAppBridge();
const redirectToTaxes = () => {
appBridge.appBridge?.dispatch(actions.Redirect({ to: "/taxes/channels" }));
};
return (
<Section.Description
title="Available channels"
@ -20,19 +13,7 @@ const Intro = () => {
<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.
<AppDashboardLink href="/taxes/channels">Configuration Taxes</AppDashboardLink> view.
</>
}
/>

View file

@ -8,12 +8,11 @@ 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({
const { mutate: updateMutation } = trpcClient.channelsConfiguration.upsert.useMutation({
onSuccess() {
notifySuccess("Success", "Updated channel configuration");
},
@ -24,14 +23,11 @@ const SelectProvider = (channelConfig: ChannelConfig) => {
const { data: providerConfigurations = [] } = trpcClient.providersConfiguration.getAll.useQuery();
const changeValue = (nextproviderConnectionId: string) => {
setValue(nextproviderConnectionId);
upsertMutation({
id,
config: {
providerConnectionId: nextproviderConnectionId,
const changeValue = (nextProviderConnectionId: string) => {
setValue(nextProviderConnectionId);
updateMutation({
providerConnectionId: nextProviderConnectionId === "" ? null : nextProviderConnectionId,
slug,
},
});
};
@ -51,7 +47,7 @@ const SelectProvider = (channelConfig: ChannelConfig) => {
};
export const ChannelTable = () => {
const { data = [] } = trpcClient.channelsConfiguration.fetch.useQuery();
const { data = [] } = trpcClient.channelsConfiguration.getAll.useQuery();
return (
<Table.Container>

View file

@ -57,7 +57,7 @@ describe("CrudSettingsService", () => {
return encryptedValue;
});
await expect(service.read("id")).rejects.toThrowError("Error while validating metadata");
await expect(service.readById("id")).rejects.toThrowError("Error while validating metadata");
});
it("throws an error if the item is not found", async () => {
@ -67,7 +67,7 @@ describe("CrudSettingsService", () => {
return encryptedValue;
});
await expect(service.read("id2")).rejects.toThrowError("Item not found");
await expect(service.readById("id2")).rejects.toThrowError("Item not found");
});
it("returns the item if found", async () => {
@ -77,7 +77,7 @@ describe("CrudSettingsService", () => {
return encryptedValue;
});
const result = await service.read("id");
const result = await service.readById("id");
expect(result).toEqual({ data: { id: "id", key: "value" } });
});
@ -109,46 +109,13 @@ describe("CrudSettingsService", () => {
});
});
describe("upsert", () => {
it("creates a new item if it doesn't exist", async () => {
vi.mocked(mockSettingsManager.get).mockImplementation(async () => {
return JSON.stringify([{ id: "id", key: "value" }]);
});
await service.upsert("id2", { key: "value2" });
expect(mockSettingsManager.set).toHaveBeenCalledWith({
domain: "apiUrl",
key: "metadataKey",
value: JSON.stringify([
{ id: "id", key: "value" },
{ id: "id2", key: "value2" },
]),
});
});
it("updates an existing item", async () => {
vi.mocked(mockSettingsManager.get).mockImplementation(async () => {
return JSON.stringify([{ id: "id", key: "value" }]);
});
await service.upsert("id", { key: "value2" });
expect(mockSettingsManager.set).toHaveBeenCalledWith({
domain: "apiUrl",
key: "metadataKey",
value: JSON.stringify([{ id: "id", key: "value2" }]),
});
});
});
describe("update", () => {
describe("updateById", () => {
it("partially updates an existing item", async () => {
vi.mocked(mockSettingsManager.get).mockImplementation(async () => {
return JSON.stringify([{ id: "id", data: [], config: { foo: "bar" } }]);
});
await service.update("id", { config: { foo: "baz" } });
await service.updateById("id", { config: { foo: "baz" } });
expect(mockSettingsManager.set).toHaveBeenCalledWith({
domain: "apiUrl",

View file

@ -25,7 +25,7 @@ export class CrudSettingsManager {
private metadataKey: string
) {
this.metadataKey = metadataKey;
this.logger = createLogger({ location: "CrudSettingsManager", metadataKey });
this.logger = createLogger({ name: "CrudSettingsManager", metadataKey });
}
async readAll() {
@ -48,7 +48,7 @@ export class CrudSettingsManager {
};
}
async read(id: string) {
async readById(id: string) {
const result = await this.readAll();
const { data: settings } = result;
@ -94,7 +94,7 @@ export class CrudSettingsManager {
});
}
async update(id: string, input: any) {
async updateById(id: string, input: any) {
const { data: currentSettings } = await this.readAll();
const nextSettings = currentSettings.map((item) => {
if (item.id === id) {
@ -105,29 +105,6 @@ export class CrudSettingsManager {
return item;
});
this.logger.debug({ nextSettings }, "nextSettings");
await this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(nextSettings),
domain: this.saleorApiUrl,
});
}
async upsert(id: string, input: any) {
const { data: currentSettings } = await this.readAll();
// update if its there
const nextSettings = currentSettings.map((item) => {
if (item.id === id) {
return { id, ...input };
}
return item;
});
if (!currentSettings.find((item) => item.id === id)) {
nextSettings.push({ id, ...input });
}
await this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(nextSettings),

View file

@ -6,7 +6,7 @@ import { PublicProviderConnectionsService } from "./public-provider-connections.
export const providerConnectionsRouter = router({
getAll: protectedClientProcedure.query(async ({ ctx }) => {
const logger = createLogger({
location: "providerConnectionsRouter.getAll",
name: "providerConnectionsRouter.getAll",
});
const items = await new PublicProviderConnectionsService(

View file

@ -13,7 +13,7 @@ export class PublicProviderConnectionsService {
this.avataxConnectionService = new PublicAvataxConnectionService(client, appId, saleorApiUrl);
this.taxJarConnectionService = new PublicTaxJarConnectionService(client, appId, saleorApiUrl);
this.logger = createLogger({
location: "PublicProviderConnectionsService",
name: "PublicProviderConnectionsService",
metadataKey: TAX_PROVIDER_KEY,
});
}

View file

@ -0,0 +1,25 @@
import { Client } from "urql";
import {
TaxClassFragment,
TaxClassesListDocument,
TaxClassesListQueryVariables,
} from "../../../generated/graphql";
export class TaxClassesFetcher {
constructor(private client: Client) {}
fetch(): Promise<TaxClassFragment[]> {
return this.client
.query(TaxClassesListDocument, {
/**
* todo: add pagination
* * arbitrary limit
*/
first: 100,
} as TaxClassesListQueryVariables)
.toPromise()
.then((r) => {
return r.data?.taxClasses?.edges.map(({ node }) => node) ?? [];
});
}
}

View file

@ -0,0 +1,20 @@
import { createLogger } from "../../lib/logger";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server";
import { TaxClassesFetcher } from "./tax-classes-fetcher";
export const taxClassesRouter = router({
getAll: protectedClientProcedure.query(async ({ ctx, input }) => {
const logger = createLogger({
name: "taxClassesRouter.getAll",
});
logger.debug("getAll called");
const taxClassesFetcher = new TaxClassesFetcher(ctx.apiClient);
logger.debug("Returning tax classes");
return taxClassesFetcher.fetch();
}),
});

View file

@ -4,6 +4,7 @@ import { MetadataItem } from "../../../generated/graphql";
import { ChannelsConfig } from "../channel-configuration/channel-config";
import { ProviderConnections } from "../provider-connections/provider-connections";
import { getActiveConnection } from "./active-connection";
import { AuthData } from "@saleor/app-sdk/APL";
const mockedInvalidMetadata: MetadataItem[] = [
{
@ -86,24 +87,32 @@ const mockedInvalidEncryptedChannels = encrypt(
const mockedValidEncryptedChannels = encrypt(JSON.stringify(mockedValidChannels), mockedSecretKey);
const mockedAuthData: AuthData = {
appId: "test-app-id",
saleorApiUrl: "https://test.saleor.io/graphql/",
token: "test-token",
};
vi.stubEnv("SECRET_KEY", mockedSecretKey);
describe("getActiveConnection", () => {
it("throws error when channel slug is missing", () => {
expect(() => getActiveConnection("", mockedInvalidMetadata)).toThrow(
expect(() => getActiveConnection("", mockedInvalidMetadata, mockedAuthData)).toThrow(
"Channel slug was not found in the webhook payload"
);
});
it("throws error when there are no metadata items", () => {
expect(() => getActiveConnection("default-channel", [])).toThrow(
expect(() => getActiveConnection("default-channel", [], mockedAuthData)).toThrow(
"App encryptedMetadata was not found in the webhook payload"
);
});
it("throws error when no providerConnectionId was found", () => {
expect(() =>
getActiveConnection("default-channel", [
getActiveConnection(
"default-channel",
[
{
key: "providers",
value: mockedEncryptedProviders,
@ -112,13 +121,17 @@ describe("getActiveConnection", () => {
key: "channels",
value: mockedInvalidEncryptedChannels,
},
])
],
mockedAuthData
)
).toThrow("Channel config providerConnectionId does not match any providers");
});
it("throws error when no channel was found for channelSlug", () => {
expect(() =>
getActiveConnection("invalid-channel", [
getActiveConnection(
"invalid-channel",
[
{
key: "providers",
value: mockedEncryptedProviders,
@ -127,12 +140,16 @@ describe("getActiveConnection", () => {
key: "channels",
value: mockedValidEncryptedChannels,
},
])
],
mockedAuthData
)
).toThrow("Channel config was not found for channel invalid-channel");
});
it("returns provider when data is correct", () => {
const result = getActiveConnection("default-channel", [
const result = getActiveConnection(
"default-channel",
[
{
key: "providers",
value: mockedEncryptedProviders,
@ -141,7 +158,9 @@ describe("getActiveConnection", () => {
key: "channels",
value: mockedValidEncryptedChannels,
},
]);
],
mockedAuthData
);
expect(result).toBeDefined();
});

View file

@ -1,3 +1,4 @@
import { AuthData } from "@saleor/app-sdk/APL";
import {
MetadataItem,
OrderCreatedSubscriptionFragment,
@ -17,9 +18,9 @@ export class ActiveTaxProvider implements ProviderWebhookService {
private logger: Logger;
private client: TaxJarWebhookService | AvataxWebhookService;
constructor(providerConnection: ProviderConnection) {
constructor(providerConnection: ProviderConnection, private authData: AuthData) {
this.logger = createLogger({
location: "ActiveTaxProvider",
name: "ActiveTaxProvider",
});
const taxProviderName = providerConnection.provider;
@ -27,13 +28,13 @@ export class ActiveTaxProvider implements ProviderWebhookService {
switch (taxProviderName) {
case "taxjar": {
this.logger.debug("Selecting TaxJar as tax provider");
this.client = new TaxJarWebhookService(providerConnection.config);
this.client = new TaxJarWebhookService(providerConnection.config, this.authData);
break;
}
case "avatax": {
this.logger.debug("Selecting Avatax as tax provider");
this.client = new AvataxWebhookService(providerConnection.config);
this.client = new AvataxWebhookService(providerConnection.config, this.authData);
break;
}
@ -58,10 +59,11 @@ export class ActiveTaxProvider implements ProviderWebhookService {
export function getActiveConnection(
channelSlug: string | undefined,
encryptedMetadata: MetadataItem[]
encryptedMetadata: MetadataItem[],
authData: AuthData
): ActiveTaxProvider {
const logger = createLogger({
location: "getActiveConnection",
name: "getActiveConnection",
});
if (!channelSlug) {
@ -74,11 +76,15 @@ export function getActiveConnection(
const { providerConnections, channels } = getAppConfig(encryptedMetadata);
if (!channels.length) {
throw new Error("You must assign a provider to the channel");
}
const channelConfig = channels.find((channel) => channel.config.slug === channelSlug);
if (!channelConfig) {
// * will happen when `order-created` webhook is triggered by creating an order in a channel that doesn't use the tax app
logger.debug({ channelSlug, channelConfig }, "Channel config was not found for channel slug");
logger.debug({ channelSlug }, "Channel config was not found for channel slug");
throw new Error(`Channel config was not found for channel ${channelSlug}`);
}
@ -94,7 +100,7 @@ export function getActiveConnection(
throw new Error(`Channel config providerConnectionId does not match any providers`);
}
const taxProvider = new ActiveTaxProvider(providerConnection);
const taxProvider = new ActiveTaxProvider(providerConnection, authData);
return taxProvider;
}

View file

@ -0,0 +1,4 @@
export type TaxCode = {
description: string;
code: string;
};

View file

@ -1,10 +1,11 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { Logger, createLogger } from "../../../lib/logger";
import { CalculateTaxesResponse } from "../../taxes/tax-provider-webhook";
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { FetchTaxForOrderArgs, TaxJarClient } from "../taxjar-client";
import { TaxJarConfig } from "../taxjar-connection-schema";
import { TaxJarCalculateTaxesPayloadTransformer } from "./taxjar-calculate-taxes-payload-transformer";
import { TaxJarCalculateTaxesPayloadService } from "./taxjar-calculate-taxes-payload-service";
import { TaxJarCalculateTaxesResponseTransformer } from "./taxjar-calculate-taxes-response-transformer";
export type TaxJarCalculateTaxesPayload = {
@ -18,29 +19,27 @@ export class TaxJarCalculateTaxesAdapter
implements WebhookAdapter<TaxJarCalculateTaxesPayload, TaxJarCalculateTaxesResponse>
{
private logger: Logger;
constructor(private readonly config: TaxJarConfig) {
this.logger = createLogger({ location: "TaxJarCalculateTaxesAdapter" });
constructor(private readonly config: TaxJarConfig, private authData: AuthData) {
this.logger = createLogger({ name: "TaxJarCalculateTaxesAdapter" });
}
// todo: refactor because its getting too big
async send(payload: TaxJarCalculateTaxesPayload): Promise<TaxJarCalculateTaxesResponse> {
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
const payloadTransformer = new TaxJarCalculateTaxesPayloadTransformer(this.config);
const target = payloadTransformer.transform(payload);
this.logger.debug("Transforming the Saleor payload for calculating taxes with TaxJar...");
const payloadService = new TaxJarCalculateTaxesPayloadService(this.config, this.authData);
const target = await payloadService.getPayload(payload);
this.logger.debug(
{ transformedPayload: target },
"Will call TaxJar fetchTaxForOrder with transformed payload:"
);
this.logger.debug("Calling TaxJar fetchTaxForOrder with transformed payload...");
const client = new TaxJarClient(this.config);
const response = await client.fetchTaxForOrder(target);
this.logger.debug({ response }, "TaxJar fetchTaxForOrder responded with:");
this.logger.debug("TaxJar fetchTaxForOrder responded with:");
const responseTransformer = new TaxJarCalculateTaxesResponseTransformer();
const transformedResponse = responseTransformer.transform(payload, response);
this.logger.debug({ transformedResponse }, "Transformed TaxJar fetchTaxForOrder response to:");
this.logger.debug("Transformed TaxJar fetchTaxForOrder response to");
return transformedResponse;
}

View file

@ -2,6 +2,7 @@ import { TaxForOrderRes } from "taxjar/dist/types/returnTypes";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { ChannelConfig } from "../../channel-configuration/channel-config";
import { TaxJarConfig } from "../taxjar-connection-schema";
import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository";
type TaxBase = TaxBaseFragment;
@ -30,12 +31,12 @@ const taxIncludedTaxBase: TaxBase = {
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOmM5MTUxMDljLTBkMzEtNDg2Yy05OGFmLTQ5NDM0MWY4NTNjYw==",
variant: {
orderProductVariant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ4",
product: {
metafield: null,
productType: {
metafield: null,
taxClass: {
id: "VGF4Q2xhc3M6MjI=",
name: "Clothing",
},
},
},
@ -52,12 +53,12 @@ const taxIncludedTaxBase: TaxBase = {
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
variant: {
orderProductVariant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ5",
product: {
metafield: null,
productType: {
metafield: null,
taxClass: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ6",
name: "Shoes",
},
},
},
@ -74,12 +75,12 @@ const taxIncludedTaxBase: TaxBase = {
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjlhMGJjZDhmLWFiMGQtNDJhOC04NTBhLTEyYjQ2YjJiNGIyZg==",
variant: {
orderProductVariant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQw",
product: {
metafield: null,
productType: {
metafield: null,
taxClass: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ6",
name: "Shoes",
},
},
},
@ -125,12 +126,12 @@ const taxExcludedTaxBase: TaxBase = {
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOmM5MTUxMDljLTBkMzEtNDg2Yy05OGFmLTQ5NDM0MWY4NTNjYw==",
variant: {
orderProductVariant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ4",
product: {
metafield: null,
productType: {
metafield: null,
taxClass: {
id: "",
name: "",
},
},
},
@ -147,12 +148,12 @@ const taxExcludedTaxBase: TaxBase = {
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
variant: {
orderProductVariant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ5",
product: {
metafield: null,
productType: {
metafield: null,
taxClass: {
id: "",
name: "",
},
},
},
@ -169,12 +170,12 @@ const taxExcludedTaxBase: TaxBase = {
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjlhMGJjZDhmLWFiMGQtNDJhOC04NTBhLTEyYjQ2YjJiNGIyZg==",
variant: {
orderProductVariant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQw",
product: {
metafield: null,
productType: {
metafield: null,
taxClass: {
id: "",
name: "",
},
},
},
@ -457,6 +458,16 @@ const withNexusTaxIncludedTaxForOrderMock: TaxForOrder = {
},
};
const defaultTaxCodeMatches: TaxJarTaxCodeMatches = [
{
data: {
taxJarTaxCode: "P0000000",
saleorTaxClassId: "VGF4Q2xhc3M6MjI=",
},
id: "VGF4Q29kZTox",
},
];
// with/without tax
const testingScenariosMap = {
with_no_nexus_tax_included: {
@ -464,24 +475,28 @@ const testingScenariosMap = {
channelConfig,
providerConfig,
response: noNexusTaxForOrderMock,
matches: defaultTaxCodeMatches,
},
with_no_nexus_tax_excluded: {
taxBase: taxExcludedTaxBase,
channelConfig,
providerConfig,
response: noNexusTaxForOrderMock,
matches: defaultTaxCodeMatches,
},
with_nexus_tax_included: {
taxBase: taxIncludedTaxBase,
channelConfig,
providerConfig,
response: withNexusTaxIncludedTaxForOrderMock,
matches: defaultTaxCodeMatches,
},
with_nexus_tax_excluded: {
taxBase: taxExcludedTaxBase,
channelConfig,
providerConfig,
response: withNexusTaxExcludedTaxForOrderMock,
matches: defaultTaxCodeMatches,
},
};
@ -512,4 +527,7 @@ export class TaxJarCalculateTaxesMockGenerator {
...testingScenariosMap[this.scenario].response,
...overrides,
});
generateTaxCodeMatches = (overrides: TaxJarTaxCodeMatches = []): TaxJarTaxCodeMatches =>
structuredClone([...testingScenariosMap[this.scenario].matches, ...overrides]);
}

View file

@ -0,0 +1,40 @@
import { discountUtils } from "../../taxes/discount-utils";
import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository";
import {
TaxJarCalculateTaxesPayload,
TaxJarCalculateTaxesTarget,
} from "./taxjar-calculate-taxes-adapter";
import { TaxJarTaxCodeMatcher } from "../tax-code/taxjar-tax-code-matcher";
export class TaxJarCalculateTaxesPayloadLinesTransformer {
transform(
taxBase: TaxJarCalculateTaxesPayload["taxBase"],
matches: TaxJarTaxCodeMatches
): TaxJarCalculateTaxesTarget["params"]["line_items"] {
const { lines, discounts } = taxBase;
const discountSum = discounts?.reduce(
(total, current) => total + Number(current.amount.amount),
0
);
const linePrices = lines.map((line) => Number(line.totalPrice.amount));
const distributedDiscounts = discountUtils.distributeDiscount(discountSum, linePrices);
const mappedLines: TaxJarCalculateTaxesTarget["params"]["line_items"] = lines.map(
(line, index) => {
const matcher = new TaxJarTaxCodeMatcher();
const discountAmount = distributedDiscounts[index];
const taxCode = matcher.match(line, matches);
return {
id: line.sourceLine.id,
product_tax_code: taxCode,
quantity: line.quantity,
unit_price: Number(line.unitPrice.amount),
discount: discountAmount,
};
}
);
return mappedLines;
}
}

View file

@ -0,0 +1,26 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { TaxJarTaxCodeMatchesService } from "../tax-code/taxjar-tax-code-matches.service";
import { TaxJarConfig } from "../taxjar-connection-schema";
import {
TaxJarCalculateTaxesPayload,
TaxJarCalculateTaxesTarget,
} from "./taxjar-calculate-taxes-adapter";
import { TaxJarCalculateTaxesPayloadTransformer } from "./taxjar-calculate-taxes-payload-transformer";
export class TaxJarCalculateTaxesPayloadService {
constructor(private readonly config: TaxJarConfig, private authData: AuthData) {}
private getMatches() {
const taxCodeMatchesService = new TaxJarTaxCodeMatchesService(this.authData);
return taxCodeMatchesService.getAll();
}
async getPayload(payload: TaxJarCalculateTaxesPayload): Promise<TaxJarCalculateTaxesTarget> {
const payloadTransformer = new TaxJarCalculateTaxesPayloadTransformer(this.config);
const matches = await this.getMatches();
return payloadTransformer.transform(payload.taxBase, matches);
}
}

View file

@ -9,9 +9,8 @@ describe("TaxJarCalculateTaxesPayloadTransformer", () => {
it("returns payload containing line_items without discounts", () => {
const taxBase = mockGenerator.generateTaxBase();
const transformedPayload = transformer.transform({
taxBase,
});
const matchesMock = mockGenerator.generateTaxCodeMatches();
const transformedPayload = transformer.transform(taxBase, matchesMock);
expect(transformedPayload).toEqual({
params: {
@ -32,7 +31,7 @@ describe("TaxJarCalculateTaxesPayloadTransformer", () => {
quantity: 3,
unit_price: 20,
discount: 0,
product_tax_code: "",
product_tax_code: "P0000000",
},
{
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
@ -61,9 +60,8 @@ describe("TaxJarCalculateTaxesPayloadTransformer", () => {
},
],
});
const transformedPayload = transformer.transform({
taxBase,
});
const matchesMock = mockGenerator.generateTaxCodeMatches();
const transformedPayload = transformer.transform(taxBase, matchesMock);
const payloadLines = transformedPayload.params.line_items ?? [];
const discountSum = payloadLines.reduce((sum, line) => sum + (line.discount ?? 0), 0);
@ -74,7 +72,7 @@ describe("TaxJarCalculateTaxesPayloadTransformer", () => {
quantity: 3,
unit_price: 20,
discount: 3.33,
product_tax_code: "",
product_tax_code: "P0000000",
},
{
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
@ -97,11 +95,10 @@ describe("TaxJarCalculateTaxesPayloadTransformer", () => {
it("throws error when no address", () => {
const mockGenerator = new TaxJarCalculateTaxesMockGenerator("with_nexus_tax_included");
const taxBase = mockGenerator.generateTaxBase({ address: null });
const matchesMock = mockGenerator.generateTaxCodeMatches();
expect(() =>
transformer.transform({
taxBase,
})
).toThrow("Customer address is required to calculate taxes in TaxJar.");
expect(() => transformer.transform(taxBase, matchesMock)).toThrow(
"Customer address is required to calculate taxes in TaxJar."
);
});
});

View file

@ -1,49 +1,21 @@
import { discountUtils } from "../../taxes/discount-utils";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { taxJarAddressFactory } from "../address-factory";
import { TaxJarTaxCodeMatches } from "../tax-code/taxjar-tax-code-match-repository";
import { TaxJarConfig } from "../taxjar-connection-schema";
import {
TaxJarCalculateTaxesPayload,
TaxJarCalculateTaxesTarget,
} from "./taxjar-calculate-taxes-adapter";
import { TaxJarCalculateTaxesPayloadLinesTransformer } from "./taxjar-calculate-taxes-payload-lines-transformer";
import { TaxJarCalculateTaxesTarget } from "./taxjar-calculate-taxes-adapter";
export class TaxJarCalculateTaxesPayloadTransformer {
constructor(private readonly config: TaxJarConfig) {}
private mapLines(
taxBase: TaxJarCalculateTaxesPayload["taxBase"]
): TaxJarCalculateTaxesTarget["params"]["line_items"] {
const { lines, discounts } = taxBase;
const discountSum = discounts?.reduce(
(total, current) => total + Number(current.amount.amount),
0
);
const linePrices = lines.map((line) => Number(line.totalPrice.amount));
const distributedDiscounts = discountUtils.distributeDiscount(discountSum, linePrices);
const mappedLines: TaxJarCalculateTaxesTarget["params"]["line_items"] = lines.map(
(line, index) => {
const discountAmount = distributedDiscounts[index];
return {
id: line.sourceLine.id,
// todo: get from tax code matcher
product_tax_code: "",
quantity: line.quantity,
unit_price: Number(line.unitPrice.amount),
discount: discountAmount,
};
}
);
return mappedLines;
}
transform({ taxBase }: TaxJarCalculateTaxesPayload): TaxJarCalculateTaxesTarget {
transform(taxBase: TaxBaseFragment, matches: TaxJarTaxCodeMatches): TaxJarCalculateTaxesTarget {
const fromAddress = taxJarAddressFactory.fromChannelToTax(this.config.address);
if (!taxBase.address) {
throw new Error("Customer address is required to calculate taxes in TaxJar.");
}
const lineTransformer = new TaxJarCalculateTaxesPayloadLinesTransformer();
const toAddress = taxJarAddressFactory.fromSaleorToTax(taxBase.address);
const taxParams: TaxJarCalculateTaxesTarget = {
@ -51,7 +23,7 @@ export class TaxJarCalculateTaxesPayloadTransformer {
...fromAddress,
...toAddress,
shipping: taxBase.shippingPrice.amount,
line_items: this.mapLines(taxBase),
line_items: lineTransformer.transform(taxBase, matches),
},
};

View file

@ -21,7 +21,7 @@ export class TaxJarConnectionRepository {
TAX_PROVIDER_KEY
);
this.logger = createLogger({
location: "TaxJarConnectionRepository",
name: "TaxJarConnectionRepository",
metadataKey: TAX_PROVIDER_KEY,
});
}
@ -62,8 +62,8 @@ export class TaxJarConnectionRepository {
return taxJarConnections;
}
async get(id: string): Promise<TaxJarConnection> {
const { data } = await this.crudSettingsManager.read(id);
async getById(id: string): Promise<TaxJarConnection> {
const { data } = await this.crudSettingsManager.readById(id);
const connection = getSchema.parse(data);
@ -80,7 +80,7 @@ export class TaxJarConnectionRepository {
}
async patch(id: string, input: Pick<TaxJarConnection, "config">): Promise<void> {
return this.crudSettingsManager.update(id, input);
return this.crudSettingsManager.updateById(id, input);
}
async delete(id: string): Promise<void> {

View file

@ -11,7 +11,7 @@ export class TaxJarConnectionService {
private taxJarConnectionRepository: TaxJarConnectionRepository;
constructor(client: Client, appId: string, saleorApiUrl: string) {
this.logger = createLogger({
location: "TaxJarConnectionService",
name: "TaxJarConnectionService",
});
const settingsManager = createSettingsManager(client, appId);
@ -24,7 +24,7 @@ export class TaxJarConnectionService {
}
getById(id: string): Promise<TaxJarConnection> {
return this.taxJarConnectionRepository.get(id);
return this.taxJarConnectionRepository.getById(id);
}
async create(config: TaxJarConfig): Promise<{ id: string }> {

View file

@ -1,4 +1,3 @@
import { CreateOrderRes } from "taxjar/dist/types/returnTypes";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { Logger, createLogger } from "../../../lib/logger";
import { CreateOrderResponse } from "../../taxes/tax-provider-webhook";
@ -19,27 +18,24 @@ export class TaxJarOrderCreatedAdapter
{
private logger: Logger;
constructor(private readonly config: TaxJarConfig) {
this.logger = createLogger({ location: "TaxJarOrderCreatedAdapter" });
this.logger = createLogger({ name: "TaxJarOrderCreatedAdapter" });
}
async send(payload: TaxJarOrderCreatedPayload): Promise<TaxJarOrderCreatedResponse> {
this.logger.debug({ payload }, "Transforming the following Saleor payload:");
this.logger.debug("Transforming the Saleor payload for creating order with TaxJar...");
const payloadTransformer = new TaxJarOrderCreatedPayloadTransformer(this.config);
const target = payloadTransformer.transform(payload);
this.logger.debug(
{ transformedPayload: target },
"Will call TaxJar fetchTaxForOrder with transformed payload:"
);
this.logger.debug("Calling TaxJar fetchTaxForOrder with transformed payload...");
const client = new TaxJarClient(this.config);
const response = await client.createOrder(target);
this.logger.debug({ response }, "TaxJar createOrder responded with:");
this.logger.debug("TaxJar createOrder successfully responded");
const responseTransformer = new TaxJarOrderCreatedResponseTransformer();
const transformedResponse = responseTransformer.transform(response);
this.logger.debug({ transformedResponse }, "Transformed TaxJar createOrder response to:");
this.logger.debug("Transformed TaxJar createOrder response");
return transformedResponse;
}

View file

@ -0,0 +1,48 @@
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
import { z } from "zod";
import { Logger, createLogger } from "../../../lib/logger";
import { CrudSettingsManager } from "../../crud-settings/crud-settings.service";
export const taxJarTaxCodeMatchSchema = z.object({
saleorTaxClassId: z.string(),
taxJarTaxCode: z.string(),
});
export type TaxJarTaxCodeMatch = z.infer<typeof taxJarTaxCodeMatchSchema>;
const taxJarTaxCodeMatchesSchema = z.array(
z.object({
id: z.string(),
data: taxJarTaxCodeMatchSchema,
})
);
export type TaxJarTaxCodeMatches = z.infer<typeof taxJarTaxCodeMatchesSchema>;
const metadataKey = "taxjar-tax-code-map";
export class TaxJarTaxCodeMatchRepository {
private crudSettingsManager: CrudSettingsManager;
private logger: Logger;
constructor(settingsManager: EncryptedMetadataManager, saleorApiUrl: string) {
this.crudSettingsManager = new CrudSettingsManager(settingsManager, saleorApiUrl, metadataKey);
this.logger = createLogger({
name: "TaxJarTaxCodeMatchRepository",
metadataKey,
});
}
async getAll(): Promise<TaxJarTaxCodeMatches> {
const { data } = await this.crudSettingsManager.readAll();
return taxJarTaxCodeMatchesSchema.parse(data);
}
async create(input: TaxJarTaxCodeMatch): Promise<void> {
await this.crudSettingsManager.create({ data: input });
}
async updateById(id: string, input: TaxJarTaxCodeMatch): Promise<void> {
await this.crudSettingsManager.updateById(id, { data: input });
}
}

View file

@ -0,0 +1,76 @@
import { TaxBaseLineFragment } from "../../../../generated/graphql";
import { TaxJarTaxCodeMatches } from "./taxjar-tax-code-match-repository";
import { TaxJarTaxCodeMatcher } from "./taxjar-tax-code-matcher";
import { describe, expect, it } from "vitest";
const matcher = new TaxJarTaxCodeMatcher();
describe("TaxJarTaxCodeMatcher", () => {
it("returns empty string when tax class is not found", () => {
const line: TaxBaseLineFragment = {
quantity: 1,
totalPrice: {
amount: 1,
},
unitPrice: {
amount: 1,
},
sourceLine: {
id: "1",
__typename: "OrderLine",
orderProductVariant: {
id: "1",
product: {},
},
},
};
const matches: TaxJarTaxCodeMatches = [
{
data: {
saleorTaxClassId: "",
taxJarTaxCode: "1",
},
id: "1",
},
];
expect(matcher.match(line, matches)).toEqual("");
});
it("returns a match when tax class is found", () => {
const line: TaxBaseLineFragment = {
quantity: 1,
totalPrice: {
amount: 1,
},
unitPrice: {
amount: 1,
},
sourceLine: {
id: "1",
__typename: "OrderLine",
orderProductVariant: {
id: "1",
product: {
taxClass: {
name: "Clothing",
id: "1",
},
},
},
},
};
const matches: TaxJarTaxCodeMatches = [
{
data: {
saleorTaxClassId: "1",
taxJarTaxCode: "123412",
},
id: "1",
},
];
const taxCode = matcher.match(line, matches);
expect(taxCode).toEqual("123412");
});
});

View file

@ -0,0 +1,27 @@
import { TaxBaseLineFragment } from "../../../../generated/graphql";
import { TaxJarTaxCodeMatches } from "./taxjar-tax-code-match-repository";
export class TaxJarTaxCodeMatcher {
private mapTaxClassWithTaxMatch(taxClassId: string, matches: TaxJarTaxCodeMatches) {
return matches.find((m) => m.data.saleorTaxClassId === taxClassId);
}
private getTaxClassId(line: TaxBaseLineFragment): string | undefined {
if (line.sourceLine.__typename === "CheckoutLine") {
return line.sourceLine.checkoutProductVariant.product.taxClass?.id;
}
if (line.sourceLine.__typename === "OrderLine") {
return line.sourceLine.orderProductVariant?.product.taxClass?.id;
}
}
match(line: TaxBaseLineFragment, matches: TaxJarTaxCodeMatches) {
const taxClassId = this.getTaxClassId(line);
// We can fall back to empty string if we don't have a tax code match
return taxClassId
? this.mapTaxClassWithTaxMatch(taxClassId, matches)?.data.taxJarTaxCode ?? ""
: "";
}
}

View file

@ -0,0 +1,40 @@
import { createLogger } from "../../../lib/logger";
import { TaxJarTaxCodeMatchesService } from "./taxjar-tax-code-matches.service";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { router } from "../../trpc/trpc-server";
import { taxJarTaxCodeMatchSchema } from "./taxjar-tax-code-match-repository";
const protectedWithTaxJarTaxCodeMatchesService = protectedClientProcedure.use(({ next, ctx }) =>
next({
ctx: {
taxCodeMatchesService: new TaxJarTaxCodeMatchesService({
saleorApiUrl: ctx.saleorApiUrl,
token: ctx.appToken!,
appId: ctx.appId!,
}),
},
})
);
export const taxJarTaxCodeMatchesRouter = router({
getAll: protectedWithTaxJarTaxCodeMatchesService.query(async ({ ctx }) => {
const logger = createLogger({
name: "taxjarTaxCodeMatchesRouter.fetch",
});
logger.info("Returning tax code matches");
return ctx.taxCodeMatchesService.getAll();
}),
upsert: protectedWithTaxJarTaxCodeMatchesService
.input(taxJarTaxCodeMatchSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({
name: "taxjarTaxCodeMatchesRouter.upsert",
});
logger.info("Upserting tax code match");
return ctx.taxCodeMatchesService.upsert(input);
}),
});

View file

@ -0,0 +1,45 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { createGraphQLClient } from "@saleor/apps-shared";
import { Logger, createLogger } from "../../../lib/logger";
import { createSettingsManager } from "../../app/metadata-manager";
import {
TaxJarTaxCodeMatch,
TaxJarTaxCodeMatchRepository,
TaxJarTaxCodeMatches,
} from "./taxjar-tax-code-match-repository";
export class TaxJarTaxCodeMatchesService {
private logger: Logger;
private taxCodeMatchRepository: TaxJarTaxCodeMatchRepository;
constructor(authData: AuthData) {
this.logger = createLogger({ name: "TaxJarTaxCodeService" });
const client = createGraphQLClient({
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
});
const settingsManager = createSettingsManager(client, authData.appId);
this.taxCodeMatchRepository = new TaxJarTaxCodeMatchRepository(
settingsManager,
authData.saleorApiUrl
);
}
async getAll(): Promise<TaxJarTaxCodeMatches> {
return this.taxCodeMatchRepository.getAll();
}
async upsert(input: TaxJarTaxCodeMatch): Promise<void | { data: { id: string } }> {
const taxCodeMatches = await this.getAll();
const taxCodeMatch = taxCodeMatches.find(
(match) => match.data.saleorTaxClassId === input.saleorTaxClassId
);
if (taxCodeMatch) {
return this.taxCodeMatchRepository.updateById(taxCodeMatch.id, input);
}
return this.taxCodeMatchRepository.create(input);
}
}

View file

@ -0,0 +1,31 @@
import { router } from "../../trpc/trpc-server";
import { createLogger } from "../../../lib/logger";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { z } from "zod";
import { TaxJarConnectionService } from "../configuration/taxjar-connection.service";
import { TaxJarTaxCodesService } from "./taxjar-tax-codes.service";
const getAllForIdSchema = z.object({ connectionId: z.string() });
export const taxJarTaxCodesRouter = router({
getAllForId: protectedClientProcedure.input(getAllForIdSchema).query(async ({ ctx, input }) => {
const logger = createLogger({
name: "taxjarTaxCodesRouter.getAllForId",
});
const connectionService = new TaxJarConnectionService(
ctx.apiClient,
ctx.appId!,
ctx.saleorApiUrl
);
const connection = await connectionService.getById(input.connectionId);
const taxCodesService = new TaxJarTaxCodesService(connection.config);
logger.debug("Returning tax codes");
return taxCodesService.getAll();
}),
});

View file

@ -0,0 +1,25 @@
import { CategoriesRes } from "taxjar/dist/types/returnTypes";
import { TaxJarClient } from "../taxjar-client";
import { TaxJarConfig } from "../taxjar-connection-schema";
import type { TaxCode } from "../../taxes/tax-code";
export class TaxJarTaxCodesService {
private client: TaxJarClient;
constructor(config: TaxJarConfig) {
this.client = new TaxJarClient(config);
}
private adapt(response: CategoriesRes): TaxCode[] {
return response.categories.map((category) => ({
description: category.name,
code: category.product_tax_code,
}));
}
async getAll() {
const response = await this.client.getTaxCodes();
return this.adapt(response);
}
}

View file

@ -29,7 +29,7 @@ export class TaxJarClient {
private logger: Logger;
constructor(providerConfig: TaxJarConfig) {
this.logger = createLogger({ location: "TaxJarClient" });
this.logger = createLogger({ name: "TaxJarClient" });
const settings = createTaxJarSettings(providerConfig);
const taxJarClient = new TaxJar(settings);
@ -54,4 +54,9 @@ export class TaxJarClient {
async validateAddress({ params }: ValidateAddressArgs) {
// return this.client.validateAddress(params);
}
async getTaxCodes() {
// ! This function doesn't accept any params. This may be troublesome if we want to do pagination/filtering on the frontend.
return this.client.categories();
}
}

View file

@ -25,7 +25,6 @@ const postInputSchema = z.object({
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
next({
ctx: {
...ctx,
connectionService: new PublicTaxJarConnectionService(
ctx.apiClient,
ctx.appId!,
@ -38,7 +37,7 @@ const protectedWithConfigurationService = protectedClientProcedure.use(({ next,
export const taxjarConnectionRouter = router({
getById: protectedWithConfigurationService.input(getInputSchema).query(async ({ ctx, input }) => {
const logger = createLogger({
location: "taxjarConnectionRouter.get",
name: "taxjarConnectionRouter.get",
});
logger.debug("taxjarConnectionRouter.get called");
@ -53,7 +52,7 @@ export const taxjarConnectionRouter = router({
.input(postInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({
location: "taxjarConnectionRouter.post",
name: "taxjarConnectionRouter.post",
});
logger.debug("Attempting to create configuration");
@ -68,7 +67,7 @@ export const taxjarConnectionRouter = router({
.input(deleteInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({
location: "taxjarConnectionRouter.delete",
name: "taxjarConnectionRouter.delete",
});
logger.debug("Route delete called");
@ -82,10 +81,10 @@ export const taxjarConnectionRouter = router({
.input(patchInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({
location: "taxjarConnectionRouter.patch",
name: "taxjarConnectionRouter.patch",
});
logger.debug({ input }, "Route patch called");
logger.debug("Route update called");
const result = await ctx.connectionService.update(input.id, input.value);

View file

@ -5,24 +5,25 @@ import { TaxJarClient } from "./taxjar-client";
import { TaxJarConfig } from "./taxjar-connection-schema";
import { TaxJarOrderCreatedAdapter } from "./order-created/taxjar-order-created-adapter";
import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
import { AuthData } from "@saleor/app-sdk/APL";
export class TaxJarWebhookService implements ProviderWebhookService {
client: TaxJarClient;
private logger: Logger;
private config: TaxJarConfig;
constructor(config: TaxJarConfig) {
constructor(config: TaxJarConfig, private authData: AuthData) {
const taxJarClient = new TaxJarClient(config);
this.client = taxJarClient;
this.config = config;
this.logger = createLogger({
location: "TaxJarWebhookService",
name: "TaxJarWebhookService",
});
}
async calculateTaxes(taxBase: TaxBaseFragment) {
const adapter = new TaxJarCalculateTaxesAdapter(this.config);
const adapter = new TaxJarCalculateTaxesAdapter(this.config, this.authData);
const response = await adapter.send({ taxBase });

View file

@ -0,0 +1,116 @@
import { useDashboardNotification } from "@saleor/apps-shared";
import React from "react";
import { trpcClient } from "../../trpc/trpc-client";
import { Table } from "../../ui/table";
import { Select } from "../../ui/_select";
import { Box, Text } from "@saleor/macaw-ui/next";
import { AppCard } from "../../ui/app-card";
const SelectTaxCode = ({ taxClassId }: { taxClassId: string }) => {
const [value, setValue] = React.useState("");
const { notifySuccess, notifyError } = useDashboardNotification();
const { data: taxJarMatches, isLoading: isMatchesLoading } =
trpcClient.taxJarMatches.getAll.useQuery();
React.useEffect(() => {
if (taxJarMatches) {
const match = taxJarMatches?.find((item) => item.data.saleorTaxClassId === taxClassId);
if (match) {
setValue(match.data.taxJarTaxCode);
}
}
}, [taxJarMatches, taxClassId]);
const { mutate: updateMutation } = trpcClient.taxJarMatches.upsert.useMutation({
onSuccess() {
notifySuccess("Success", "Updated TaxJar tax code matches");
},
onError(error) {
notifyError("Error", error.message);
},
});
const { data: providers } = trpcClient.providersConfiguration.getAll.useQuery();
/*
* Tax Code Matcher is only available when there is at least one connection.
* The reason for it is that we need any working credentials to fetch the provider tax codes.
*/
const firstConnectionId = providers?.[0].id;
const { data: taxCodes = [], isLoading: isCodesLoading } =
trpcClient.taxJarTaxCodes.getAllForId.useQuery(
{
connectionId: firstConnectionId!,
},
{
enabled: firstConnectionId !== undefined,
}
);
const changeValue = (taxJarTaxCode: string) => {
setValue(taxJarTaxCode);
updateMutation({
saleorTaxClassId: taxClassId,
taxJarTaxCode,
});
};
const isLoading = isMatchesLoading || isCodesLoading;
return (
<Select
value={value ?? ""}
disabled={isLoading}
onChange={(value) => changeValue(String(value))}
options={[
...(isLoading
? [{ value: "", label: "Loading..." }]
: [{ value: "", label: "Not assigned" }]),
...taxCodes.map((item) => ({
value: item.code,
label: `${item.code} | ${item.description}`,
})),
]}
/>
);
};
export const TaxJarTaxCodeMatcherTable = () => {
const { data: taxClasses = [], isLoading } = trpcClient.taxClasses.getAll.useQuery();
if (isLoading) {
return (
<Box>
<Text color="textNeutralSubdued">Loading...</Text>
</Box>
);
}
return (
<AppCard>
<Table.Container>
<Table.THead>
<Table.TR>
<Table.TH>Saleor tax class</Table.TH>
<Table.TH>TaxJar tax code</Table.TH>
</Table.TR>
</Table.THead>
<Table.TBody>
{taxClasses.map((taxClass) => {
return (
<Table.TR key={taxClass.id}>
<Table.TD>{taxClass.name}</Table.TD>
<Table.TD>
<SelectTaxCode taxClassId={taxClass.id} />
</Table.TD>
</Table.TR>
);
})}
</Table.TBody>
</Table.Container>
</AppCard>
);
};

View file

@ -3,7 +3,7 @@ import { middleware, procedure } from "./trpc-server";
import { saleorApp } from "../../../saleor-app";
import { TRPCError } from "@trpc/server";
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
import { logger } from "../../lib/logger";
import { createLogger, logger } from "../../lib/logger";
import { createGraphQLClient } from "@saleor/apps-shared";
const attachAppToken = middleware(async ({ ctx, next }) => {
@ -85,7 +85,6 @@ const validateClientToken = middleware(async ({ ctx, next, meta }) => {
return next({
ctx: {
...ctx,
saleorApiUrl: ctx.saleorApiUrl,
},
});
@ -99,7 +98,21 @@ const validateClientToken = middleware(async ({ ctx, next, meta }) => {
*
* TODO Rethink middleware composition to enable safe server-side router calls
*/
const logErrors = middleware(async ({ next }) => {
const logger = createLogger({ name: "trpcServer" });
const result = await next();
if (!result.ok) {
logger.error(result.error);
}
return result;
});
export const protectedClientProcedure = procedure
.use(logErrors)
.use(attachAppToken)
.use(validateClientToken)
.use(async ({ ctx, next }) => {

View file

@ -3,12 +3,26 @@ import { providerConnectionsRouter } from "../provider-connections/provider-conn
import { channelsConfigurationRouter } from "../channel-configuration/channel-configuration.router";
import { taxjarConnectionRouter } from "../taxjar/taxjar-connection.router";
import { avataxConnectionRouter } from "../avatax/avatax-connection.router";
import { taxClassesRouter } from "../tax-classes/tax-classes.router";
import { avataxTaxCodesRouter } from "../avatax/tax-code/avatax-tax-codes.router";
import { taxJarTaxCodesRouter } from "../taxjar/tax-code/taxjar-tax-codes.router";
import { taxJarTaxCodeMatchesRouter } from "../taxjar/tax-code/taxjar-tax-code-matches.router";
import { avataxTaxCodeMatchesRouter } from "../avatax/tax-code/avatax-tax-code-matches.router";
/*
* // todo: split to namespaces, e.g.:
* avatax: { connection, taxCodes, taxCodeMatches }
*/
export const appRouter = router({
providersConfiguration: providerConnectionsRouter,
channelsConfiguration: channelsConfigurationRouter,
taxJarConnection: taxjarConnectionRouter,
avataxConnection: avataxConnectionRouter,
taxClasses: taxClassesRouter,
avataxTaxCodes: avataxTaxCodesRouter,
taxJarTaxCodes: taxJarTaxCodesRouter,
taxJarMatches: taxJarTaxCodeMatchesRouter,
avataxMatches: avataxTaxCodeMatchesRouter,
});
export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,17 @@
import { Select as _Select } from "@saleor/macaw-ui/next";
type SelectProps = React.ComponentProps<typeof _Select>;
/**
* The macaw-ui Select doesn't truncate the label text, so we need to override it.
* @see: https://github.com/saleor/macaw-ui/issues/477
*/
export const Select = (props: SelectProps) => {
return (
<_Select
{...props}
__whiteSpace={"preline"}
__overflow={"hidden"}
__textOverflow={"ellipsis"}
/>
);
};

View file

@ -33,7 +33,17 @@ const breadcrumbsForRoute: Record<string, Breadcrumb[]> = {
href: "/providers/taxjar",
},
],
"/providers/taxjar/matcher": [
...newProviderBreadcrumbs,
{
label: "TaxJar",
href: "/providers/taxjar",
},
{
label: "Tax code matcher",
href: "/providers/taxjar/matcher",
},
],
"/providers/taxjar/[id]": [
...newProviderBreadcrumbs,
{
@ -48,6 +58,17 @@ const breadcrumbsForRoute: Record<string, Breadcrumb[]> = {
href: "/providers/avatax",
},
],
"/providers/avatax/matcher": [
...newProviderBreadcrumbs,
{
label: "Avatax",
href: "/providers/avatax",
},
{
label: "Tax code matcher",
href: "/providers/avatax/matcher",
},
],
"/providers/avatax/[id]": [
...newProviderBreadcrumbs,
{
@ -61,7 +82,7 @@ const useBreadcrumbs = () => {
const { pathname } = useRouter();
const breadcrumbs = breadcrumbsForRoute[pathname];
if (pathname !== "/" && !breadcrumbs) {
if (pathname !== "/" && pathname !== "_error" && !breadcrumbs) {
throw new Error(`No breadcrumbs for route ${pathname}`);
}

View file

@ -3,9 +3,9 @@ import React, { PropsWithChildren } from "react";
export const AppColumns = ({ top, children }: PropsWithChildren<{ top: React.ReactNode }>) => {
return (
<Box display={"grid"} __gap={"60px"}>
<Box display={"grid"} __gap={"64px"} __marginBottom={"64px"}>
<Box>{top}</Box>
<Box display={"grid"} gap={10} __gridTemplateColumns={"1fr 1fr"}>
<Box display={"grid"} __gridTemplateColumns={"1fr 1fr"} __rowGap={"96px"} columnGap={10}>
{children}
</Box>
</Box>

View file

@ -0,0 +1,35 @@
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Text } from "@saleor/macaw-ui/next";
import { PropsWithChildren } from "react";
/*
* * ui/TextLink currently supports either in-app links or external links. This is in-dashboard link.
* // todo: move the logic to TextLink
*/
export const AppDashboardLink = ({
children,
href,
...rest
}: PropsWithChildren<{ href: string }>) => {
const appBridge = useAppBridge();
const redirectToDashboardPath = () => {
appBridge.appBridge?.dispatch(actions.Redirect({ to: href }));
};
return (
<Text
transition={"ease"}
variant={"bodyStrong"}
cursor={"pointer"}
color={{
default: "text3Decorative",
hover: "text1Decorative",
}}
onClick={redirectToDashboardPath}
{...rest}
>
{children}
</Text>
);
};

View file

@ -0,0 +1,90 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { trpcClient } from "../trpc/trpc-client";
import { AppCard } from "./app-card";
import { Section } from "./app-section";
import { ProviderLabel } from "./provider-label";
import { Table } from "./table";
import { useRouter } from "next/router";
const MatcherTable = () => {
const { data: connections = [] } = trpcClient.providersConfiguration.getAll.useQuery();
const isAvatax = connections.some(({ provider }) => provider === "avatax");
const isTaxJar = connections.some(({ provider }) => provider === "taxjar");
const router = useRouter();
return (
<AppCard __minHeight={"320px"} height="100%">
<Table.Container>
<Table.THead>
<Table.TR>
<Table.TH>Provider</Table.TH>
</Table.TR>
</Table.THead>
<Table.TBody>
{isAvatax && (
<Table.TR>
<Table.TD>
<ProviderLabel name="avatax" />
</Table.TD>
<Table.TD>
<Box display="flex" justifyContent={"flex-end"}>
<Button
onClick={() => router.push("/providers/avatax/matcher")}
variant="tertiary"
>
Configure
</Button>{" "}
</Box>{" "}
</Table.TD>
</Table.TR>
)}
{isTaxJar && (
<Table.TR>
<Table.TD>
<ProviderLabel name="taxjar" />
</Table.TD>
<Table.TD>
<Box display="flex" justifyContent={"flex-end"}>
<Button
onClick={() => router.push("/providers/taxjar/matcher")}
variant="tertiary"
>
Configure
</Button>{" "}
</Box>
</Table.TD>
</Table.TR>
)}
</Table.TBody>
</Table.Container>
</AppCard>
);
};
const Intro = () => {
return (
<Section.Description
title="Tax code matcher"
description={
<>
Tax Code Matcher allows you to map Saleor tax classes to provider tax codes to extend
products base tax rate.
<Text as="span" display="block" marginY={4}>
You need to have at least one provider configured to use this feature.
</Text>
</>
}
/>
);
};
export const MatcherSection = () => {
return (
<>
<Intro />
<MatcherTable />
</>
);
};

View file

@ -37,7 +37,7 @@ export const checkoutCalculateTaxesSyncWebhook = new SaleorSyncWebhook<Calculate
});
export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx) => {
const logger = createLogger({ location: "checkoutCalculateTaxesSyncWebhook" });
const logger = createLogger({ name: "checkoutCalculateTaxesSyncWebhook" });
const { payload } = ctx;
const webhookResponse = new WebhookResponse(res);
@ -54,7 +54,7 @@ export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res,
try {
const appMetadata = payload.recipient?.privateMetadata ?? [];
const channelSlug = payload.taxBase.channel.slug;
const taxProvider = getActiveConnection(channelSlug, appMetadata);
const taxProvider = getActiveConnection(channelSlug, appMetadata, ctx.authData);
logger.info({ taxProvider }, "Will calculate taxes using the tax provider:");
const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase);

View file

@ -37,7 +37,7 @@ export const orderCalculateTaxesSyncWebhook = new SaleorSyncWebhook<CalculateTax
});
export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx) => {
const logger = createLogger({ location: "orderCalculateTaxesSyncWebhook" });
const logger = createLogger({ name: "orderCalculateTaxesSyncWebhook" });
const { payload } = ctx;
const webhookResponse = new WebhookResponse(res);
@ -54,7 +54,7 @@ export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx
try {
const appMetadata = payload.recipient?.privateMetadata ?? [];
const channelSlug = payload.taxBase.channel.slug;
const taxProvider = getActiveConnection(channelSlug, appMetadata);
const taxProvider = getActiveConnection(channelSlug, appMetadata, ctx.authData);
logger.info({ taxProvider }, "Will calculate taxes using the tax provider:");
const calculatedTaxes = await taxProvider.calculateTaxes(payload.taxBase);

View file

@ -74,7 +74,7 @@ export default orderCreatedAsyncWebhook.createHandler(async (req, res, ctx) => {
try {
const appMetadata = payload.recipient?.privateMetadata ?? [];
const channelSlug = payload.order?.channel.slug;
const taxProvider = getActiveConnection(channelSlug, appMetadata);
const taxProvider = getActiveConnection(channelSlug, appMetadata, ctx.authData);
logger.info({ taxProvider }, "Fetched taxProvider");

View file

@ -36,7 +36,7 @@ export default orderFulfilledAsyncWebhook.createHandler(async (req, res, ctx) =>
try {
const appMetadata = payload.recipient?.privateMetadata ?? [];
const channelSlug = payload.order?.channel.slug;
const taxProvider = getActiveConnection(channelSlug, appMetadata);
const taxProvider = getActiveConnection(channelSlug, appMetadata, ctx.authData);
logger.info({ taxProvider }, "Fetched taxProvider");

View file

@ -1,7 +1,9 @@
import { ChannelSection } from "../modules/channel-configuration/ui/channel-section";
import { ProvidersSection } from "../modules/provider-connections/ui/providers-section";
import { trpcClient } from "../modules/trpc/trpc-client";
import { AppColumns } from "../modules/ui/app-columns";
import { Section } from "../modules/ui/app-section";
import { MatcherSection } from "../modules/ui/matcher-section";
const Header = () => {
return (
@ -12,10 +14,14 @@ const Header = () => {
};
const ConfigurationPage = () => {
const { data: providers = [] } = trpcClient.providersConfiguration.getAll.useQuery();
const isProviders = providers.length > 0;
return (
<AppColumns top={<Header />}>
<ProvidersSection />
<ChannelSection />
{isProviders && <MatcherSection />}
</AppColumns>
);
};

View file

@ -0,0 +1,58 @@
import { Text } from "@saleor/macaw-ui/next";
import { AvataxTaxCodeMatcherTable } from "../../../modules/avatax/ui/avatax-tax-code-matcher-table";
import { AppColumns } from "../../../modules/ui/app-columns";
import { AppDashboardLink } from "../../../modules/ui/app-dashboard-link";
import { Section } from "../../../modules/ui/app-section";
import { TextLink } from "@saleor/apps-ui";
const Header = () => {
return <Section.Header>Match Saleor tax classes to Avatax tax codes</Section.Header>;
};
const Description = () => {
return (
<Section.Description
title="Avatax tax code matcher"
description={
<>
<Text display="block" as="span" marginBottom={4}>
To extend the base tax rate of your products, you can map Saleor tax classes to Avatax
tax codes.
</Text>
<Text display="block" as="span" marginBottom={4}>
This way, the product&apos;s Saleor tax class will be used to determine the Avatax tax
code needed to calculate the tax rate.
</Text>
<Text as="p" marginBottom={4}>
If you haven&apos;t created any tax classes yet, you can do it in the{" "}
<AppDashboardLink href="/taxes/tax-classes">
Configuration Taxes Tax classes
</AppDashboardLink>{" "}
view.
</Text>
<Text as="p" marginBottom={4}>
To learn more about Avatax tax codes, please visit{" "}
<TextLink href="https://taxcode.avatax.avalara.com/search?q=OF400000" newTab>
Avatax documentation
</TextLink>
.
</Text>
</>
}
/>
);
};
const AvataxMatcher = () => {
return (
<AppColumns top={<Header />}>
<Description />
<AvataxTaxCodeMatcherTable />
</AppColumns>
);
};
/*
* todo: add redirect if no connection
*/
export default AvataxMatcher;

View file

@ -0,0 +1,61 @@
import { Text } from "@saleor/macaw-ui/next";
import { AppColumns } from "../../../modules/ui/app-columns";
import { AppDashboardLink } from "../../../modules/ui/app-dashboard-link";
import { Section } from "../../../modules/ui/app-section";
import { TextLink } from "@saleor/apps-ui";
import { TaxJarTaxCodeMatcherTable } from "../../../modules/taxjar/ui/taxjar-tax-code-matcher-table";
const Header = () => {
return <Section.Header>Match Saleor tax classes to TaxJar tax categories</Section.Header>;
};
const Description = () => {
return (
<Section.Description
title="TaxJar tax code matcher"
description={
<>
<Text display="block" as="span" marginBottom={4}>
To extend the base tax rate of your products, you can map Saleor tax classes to TaxJar
tax categories.
</Text>
<Text display="block" as="span" marginBottom={4}>
This way, the product&apos;s Saleor tax class will be used to determine the TaxJar tax
code needed to calculate the tax rate.
</Text>
<Text as="p" marginBottom={4}>
If you haven&apos;t created any tax classes yet, you can do it in the{" "}
<AppDashboardLink href="/taxes/tax-classes">
Configuration Taxes Tax classes
</AppDashboardLink>{" "}
view.
</Text>
<Text as="p" marginBottom={4}>
To learn more about TaxJar tax categories, please visit{" "}
<TextLink
href="https://support.taxjar.com/article/555-what-product-tax-codes-does-taxjar-support"
newTab
>
TaxJar documentation
</TextLink>
.
</Text>
</>
}
/>
);
};
const TaxJarMatcher = () => {
return (
<AppColumns top={<Header />}>
<Description />
<TaxJarTaxCodeMatcherTable />
</AppColumns>
);
};
/*
* todo: add redirect if no connection
*/
export default TaxJarMatcher;