feat: taxes map entity type (#808)

* feat:  add privateMetadata to order in subscription

* feat:  add avatax-entity-type-matcher

* test:  add tests for entity-type-matcher

* refactor: ♻️ use metadata instead of privateMetadata

* refactor: ♻️ replace brs

* chore: 💡 remove todo comment

* build: 👷 add changeset

* refactor: ♻️ graphql queries with metafield instead metadata
This commit is contained in:
Adrian Pilarczyk 2023-07-27 15:23:54 +02:00 committed by GitHub
parent e9531ce79f
commit a725720920
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 189 additions and 58 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-taxes": minor
---
Added the possibility to pass entityUseCode as order `avataxEntityCode` metadata field. This makes tax exempting groups of customers possible.

View file

@ -61,11 +61,13 @@ fragment TaxBase on TaxableObject {
}
sourceObject {
... on Checkout {
avataxEntityCode: metafield(key: "avataxEntityCode")
user {
id
}
}
... on Order {
avataxEntityCode: metafield(key: "avataxEntityCode")
user {
id
}

View file

@ -64,6 +64,7 @@ fragment OrderCreatedSubscription on Order {
amount
}
}
avataxEntityCode: metafield(key: "avataxEntityCode")
}
fragment OrderCreatedEventSubscription on Event {
__typename

View file

@ -6,6 +6,7 @@ export const defaultOrder: OrderCreatedSubscriptionFragment = {
id: "VXNlcjoyMDg0NTEwNDEw",
email: "happy.customer@saleor.io",
},
avataxEntityCode: null,
created: "2023-05-25T09:18:55.203440+00:00",
status: OrderStatus.Unfulfilled,
channel: {

View file

@ -86,4 +86,11 @@ export class AvataxClient {
async ping() {
return this.client.ping();
}
async getEntityUseCode(useCode: string) {
return this.client.listEntityUseCodes({
// https://developer.avalara.com/avatax/filtering-in-rest/
filter: `code eq ${useCode}`,
});
}
}

View file

@ -0,0 +1,44 @@
import { AvataxClient } from "./avatax-client";
import { AvataxEntityTypeMatcher } from "./avatax-entity-type-matcher";
import { describe, expect, it, vi } from "vitest";
const mockGetEntityUseCode = vi.fn();
describe("AvataxEntityTypeMatcher", () => {
it("returns empty string when no entity code", async () => {
const mockAvataxClient = {
getEntityUseCode: mockGetEntityUseCode.mockReturnValue(
Promise.resolve({ value: [{ code: "entityCode" }] })
),
} as any as AvataxClient;
const matcher = new AvataxEntityTypeMatcher({ client: mockAvataxClient });
const result = await matcher.match(null);
expect(result).toBe("");
});
it("returns empty string when entity code is present in metadata but not in avatax", async () => {
const mockAvataxClient = {
getEntityUseCode: mockGetEntityUseCode.mockReturnValue(Promise.resolve({})),
} as any as AvataxClient;
const matcher = new AvataxEntityTypeMatcher({ client: mockAvataxClient });
const result = await matcher.match("entityCode");
expect(result).toBe("");
});
it("returns entity code when entity code is present in metadata and in avatax", async () => {
const mockAvataxClient = {
getEntityUseCode: mockGetEntityUseCode.mockReturnValue(
Promise.resolve({ value: [{ code: "entityCode" }] })
),
} as any as AvataxClient;
const matcher = new AvataxEntityTypeMatcher({ client: mockAvataxClient });
const result = await matcher.match("entityCode");
expect(result).toBe("entityCode");
});
});

View file

@ -0,0 +1,45 @@
import { Logger, createLogger } from "../../lib/logger";
import { AvataxClient } from "./avatax-client";
/*
* Arbitrary key-value pair that is used to store the entity code in the metadata.
* see: https://docs.saleor.io/docs/3.x/developer/app-store/apps/taxes/avatax#mapping-the-entity-type
*/
const AVATAX_ENTITY_CODE = "avataxEntityCode";
export class AvataxEntityTypeMatcher {
private client: AvataxClient;
private logger: Logger;
constructor({ client }: { client: AvataxClient }) {
this.client = client;
this.logger = createLogger({
name: "AvataxEntityTypeMatcher",
});
}
private returnFallback() {
// Empty string will be treated as non existing entity code.
return "";
}
private async validateEntityCode(entityCode: string) {
const result = await this.client.getEntityUseCode(entityCode);
// If verified, return the entity code. If not, return empty string.
return result.value?.[0].code || this.returnFallback();
}
async match(entityCode: string | null | undefined) {
if (!entityCode) {
return this.returnFallback();
}
try {
return this.validateEntityCode(entityCode);
} catch (error) {
this.logger.debug({ error }, "Failed to verify entity code");
return this.returnFallback();
}
}
}

View file

@ -106,6 +106,7 @@ const defaultTaxBase: TaxBase = {
},
],
sourceObject: {
avataxEntityCode: null,
user: {
id: "VXNlcjoyMDg0NTEwNDEw",
},

View file

@ -7,11 +7,11 @@ const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
describe("AvataxCalculateTaxesPayloadTransformer", () => {
it("returns document type of SalesInvoice", () => {
it("returns document type of SalesInvoice", async () => {
const taxBaseMock = mockGenerator.generateTaxBase();
const matchesMock = mockGenerator.generateTaxCodeMatches();
const payload = new AvataxCalculateTaxesPayloadTransformer().transform(
const payload = await new AvataxCalculateTaxesPayloadTransformer().transform(
taxBaseMock,
avataxConfigMock,
matchesMock
@ -19,11 +19,11 @@ describe("AvataxCalculateTaxesPayloadTransformer", () => {
expect(payload.model.type).toBe(DocumentType.SalesOrder);
});
it("when discounts, calculates the sum of discounts", () => {
it("when discounts, calculates the sum of discounts", async () => {
const taxBaseMock = mockGenerator.generateTaxBase({ discounts: [{ amount: { amount: 10 } }] });
const matchesMock = mockGenerator.generateTaxCodeMatches();
const payload = new AvataxCalculateTaxesPayloadTransformer().transform(
const payload = await new AvataxCalculateTaxesPayloadTransformer().transform(
taxBaseMock,
avataxConfigMock,
matchesMock
@ -31,11 +31,13 @@ describe("AvataxCalculateTaxesPayloadTransformer", () => {
expect(payload.model.discount).toEqual(10);
});
it("when no discounts, the sum of discount is 0", () => {
it("when no discounts, the sum of discount is 0", async () => {
const mockGenerator = new AvataxCalculateTaxesMockGenerator();
const avataxConfigMock = mockGenerator.generateAvataxConfig();
const taxBaseMock = mockGenerator.generateTaxBase();
const matchesMock = mockGenerator.generateTaxCodeMatches();
const payload = new AvataxCalculateTaxesPayloadTransformer().transform(
const payload = await new AvataxCalculateTaxesPayloadTransformer().transform(
taxBaseMock,
avataxConfigMock,
matchesMock

View file

@ -2,32 +2,37 @@ import { DocumentType } from "avatax/lib/enums/DocumentType";
import { TaxBaseFragment } from "../../../../generated/graphql";
import { discountUtils } from "../../taxes/discount-utils";
import { avataxAddressFactory } from "../address-factory";
import { CreateTransactionArgs } from "../avatax-client";
import { AvataxClient, CreateTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
import { AvataxCalculateTaxesPayloadLinesTransformer } from "./avatax-calculate-taxes-payload-lines-transformer";
import { AvataxEntityTypeMatcher } from "../avatax-entity-type-matcher";
export class AvataxCalculateTaxesPayloadTransformer {
private matchDocumentType(config: AvataxConfig): DocumentType {
/*
* * For calculating taxes, we always use DocumentType.SalesOrder because it doesn't cause transaction recording.
* * The full flow is described here: https://developer.avalara.com/ecommerce-integration-guide/sales-tax-badge/design-document-workflow/should-i-commit/
* * config.isDocumentRecordingEnabledEnabled is used to determine if the transaction should be recorded (hence if the document type should be SalesOrder).
* * config.isDocumentRecordingEnabled is used to determine if the transaction should be recorded (hence if the document type should be SalesOrder).
* * Given that we never want to record the transaction in calculate taxes, we always return DocumentType.SalesOrder.
*/
return DocumentType.SalesOrder;
}
transform(
async transform(
taxBase: TaxBaseFragment,
avataxConfig: AvataxConfig,
matches: AvataxTaxCodeMatches
): CreateTransactionArgs {
): Promise<CreateTransactionArgs> {
const payloadLinesTransformer = new AvataxCalculateTaxesPayloadLinesTransformer();
const avataxClient = new AvataxClient(avataxConfig);
const entityTypeMatcher = new AvataxEntityTypeMatcher({ client: avataxClient });
const entityUseCode = await entityTypeMatcher.match(taxBase.sourceObject.avataxEntityCode);
return {
model: {
type: this.matchDocumentType(avataxConfig),
entityUseCode,
customerCode: taxBase.sourceObject.user?.id ?? "",
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

View file

@ -22,13 +22,13 @@ const transformer = new AvataxOrderCreatedPayloadTransformer();
export const avataxConfigMock = mockGenerator.generateAvataxConfig();
describe("AvataxOrderCreatedPayloadTransformer", () => {
it("returns document type of SalesInvoice when isDocumentRecordingEnabled is true", () => {
const payload = transformer.transform(orderMock, avataxConfigMock, []);
it("returns document type of SalesInvoice when isDocumentRecordingEnabled is true", async () => {
const payload = await transformer.transform(orderMock, avataxConfigMock, []);
expect(payload.model.type).toBe(DocumentType.SalesInvoice);
}),
it("returns document type of SalesOrder when isDocumentRecordingEnabled is false", () => {
const payload = transformer.transform(
it("returns document type of SalesOrder when isDocumentRecordingEnabled is false", async () => {
const payload = await transformer.transform(
orderMock,
{
...avataxConfigMock,
@ -39,16 +39,17 @@ describe("AvataxOrderCreatedPayloadTransformer", () => {
expect(payload.model.type).toBe(DocumentType.SalesOrder);
});
it("returns lines with discounted: true when there are discounts", () => {
const payload = transformer.transform(discountedOrderMock, avataxConfigMock, []);
it("returns lines with discounted: true when there are discounts", async () => {
const payload = await transformer.transform(discountedOrderMock, avataxConfigMock, []);
const linesWithoutShipping = payload.model.lines.slice(0, -1);
const check = linesWithoutShipping.every((line) => line.discounted === true);
expect(check).toBe(true);
});
it("returns lines with discounted: false when there are no discounts", () => {
const payload = transformer.transform(orderMock, avataxConfigMock, []);
it("returns lines with discounted: false when there are no discounts", async () => {
const transformer = new AvataxOrderCreatedPayloadTransformer();
const payload = await transformer.transform(orderMock, avataxConfigMock, []);
const linesWithoutShipping = payload.model.lines.slice(0, -1);
const check = linesWithoutShipping.every((line) => line.discounted === false);

View file

@ -2,10 +2,11 @@ import { DocumentType } from "avatax/lib/enums/DocumentType";
import { OrderCreatedSubscriptionFragment } from "../../../../generated/graphql";
import { discountUtils } from "../../taxes/discount-utils";
import { avataxAddressFactory } from "../address-factory";
import { CreateTransactionArgs } from "../avatax-client";
import { AvataxClient, CreateTransactionArgs } from "../avatax-client";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
import { AvataxOrderCreatedPayloadLinesTransformer } from "./avatax-order-created-payload-lines-transformer";
import { AvataxEntityTypeMatcher } from "../avatax-entity-type-matcher";
export const SHIPPING_ITEM_CODE = "Shipping";
@ -18,16 +19,20 @@ export class AvataxOrderCreatedPayloadTransformer {
return DocumentType.SalesInvoice;
}
transform(
async transform(
order: OrderCreatedSubscriptionFragment,
avataxConfig: AvataxConfig,
matches: AvataxTaxCodeMatches
): CreateTransactionArgs {
): Promise<CreateTransactionArgs> {
const linesTransformer = new AvataxOrderCreatedPayloadLinesTransformer();
const avataxClient = new AvataxClient(avataxConfig);
const entityTypeMatcher = new AvataxEntityTypeMatcher({ client: avataxClient });
const entityUseCode = await entityTypeMatcher.match(order.avataxEntityCode);
return {
model: {
type: this.matchDocumentType(avataxConfig),
entityUseCode,
customerCode:
order.user?.id ??
"" /* In Saleor Avatax plugin, the customer code is 0. In Taxes App, we set it to the user id. */,

View file

@ -1,3 +1,4 @@
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";
@ -9,15 +10,17 @@ const Intro = () => {
data-testid="channel-intro"
description={
<>
This table displays all the channels configured to use the tax app as the tax calculation
method.
<br />
<br />
You can change the tax configuration method for each channel in the{" "}
<AppDashboardLink data-testid="configuration-taxes-text-link" href="/taxes/channels">
Configuration Taxes
</AppDashboardLink>{" "}
view.
<Text as="p" marginBottom={4}>
This table displays all the channels configured to use the tax app as the tax
calculation method.
</Text>
<Text as="p">
You can change the tax configuration method for each channel in the{" "}
<AppDashboardLink data-testid="configuration-taxes-text-link" href="/taxes/channels">
Configuration Taxes
</AppDashboardLink>{" "}
view.
</Text>
</>
}
/>

View file

@ -1,6 +1,7 @@
import { TextLink } from "@saleor/apps-ui";
import { Section } from "../../ui/app-section";
import { ProvidersList } from "./providers-list";
import { Text } from "@saleor/macaw-ui/next";
const Intro = () => {
return (
@ -9,18 +10,20 @@ const Intro = () => {
data-testid="providers-intro"
description={
<>
Saleor offers two ways of calculating taxes: flat or dynamic rates.
<br />
<br />
Taxes App leverages the dynamic rates by delegating the tax calculation to third-party
services.
<br />
<br />
You can read more about how Saleor deals with taxes in{" "}
<TextLink newTab href="https://docs.saleor.io/docs/3.x/developer/taxes">
our documentation
</TextLink>
.
<Text as="p" marginBottom={4}>
Saleor offers two ways of calculating taxes: flat or dynamic rates.
</Text>
<Text as="p" marginBottom={4}>
Taxes App leverages the dynamic rates by delegating the tax calculation to third-party
services.
</Text>
<Text as="p">
You can read more about how Saleor deals with taxes in{" "}
<TextLink newTab href="https://docs.saleor.io/docs/3.x/developer/taxes">
our documentation
</TextLink>
.
</Text>
</>
}
/>

View file

@ -95,6 +95,7 @@ const taxIncludedTaxBase: TaxBase = {
},
],
sourceObject: {
avataxEntityCode: null,
user: {
id: "VXNlcjoyMDg0NTEwNDEw",
},
@ -148,6 +149,7 @@ const taxExcludedTaxBase: TaxBase = {
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjUxZDc2ZDY1LTFhYTgtNGEzMi1hNWJhLTJkZDMzNjVhZDhlZQ==",
orderProductVariant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQ5",
product: {
@ -170,6 +172,7 @@ const taxExcludedTaxBase: TaxBase = {
sourceLine: {
__typename: "OrderLine",
id: "T3JkZXJMaW5lOjlhMGJjZDhmLWFiMGQtNDJhOC04NTBhLTEyYjQ2YjJiNGIyZg==",
orderProductVariant: {
id: "UHJvZHVjdFZhcmlhbnQ6MzQw",
product: {
@ -190,6 +193,7 @@ const taxExcludedTaxBase: TaxBase = {
},
],
sourceObject: {
avataxEntityCode: null,
user: {
id: "VXNlcjoyMDg0NTEwNDEw",
},

View file

@ -8,11 +8,13 @@ export const TaxJarInstructions = () => {
title={"TaxJar Configuration"}
description={
<>
The form consists of two sections: <i>Credentials</i> and <i>Address</i>.
<br />
<br />
<i>Credentials</i> will fail if:
<Box as="ol" margin={0}>
<Text as="p" marginBottom={4}>
The form consists of two sections: <i>Credentials</i> and <i>Address</i>.
</Text>
<Text as="p">
<i>Credentials</i> will fail if:
</Text>
<Box as="ol" margin={0} marginBottom={4}>
<li>
<Text>- The API Key is incorrect.</Text>
</li>
@ -20,10 +22,10 @@ export const TaxJarInstructions = () => {
<Text>- The API Key does not match &quot;sandbox mode&quot; setting.</Text>
</li>
</Box>
<br />
<br />
<i>Address</i> will fail if:
<Box as="ol" margin={0}>
<Text as="p" marginBottom={4}>
<i>Address</i> will fail if:
</Text>
<Box as="ol" margin={0} marginBottom={4}>
<li>
<Text>
- The address does not match{" "}
@ -34,13 +36,13 @@ export const TaxJarInstructions = () => {
</Text>
</li>
</Box>
<br />
<br />
If the configuration fails, please visit the{" "}
<TextLink href="https://developers.taxjar.com/api/reference/" newTab>
TaxJar documentation
</TextLink>
.
<Text as="p" marginBottom={4}>
If the configuration fails, please visit the{" "}
<TextLink href="https://developers.taxjar.com/api/reference/" newTab>
TaxJar documentation
</TextLink>
.
</Text>
</>
}
/>