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:
parent
e9531ce79f
commit
a725720920
16 changed files with 189 additions and 58 deletions
5
.changeset/chatty-mugs-accept.md
Normal file
5
.changeset/chatty-mugs-accept.md
Normal 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.
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -64,6 +64,7 @@ fragment OrderCreatedSubscription on Order {
|
|||
amount
|
||||
}
|
||||
}
|
||||
avataxEntityCode: metafield(key: "avataxEntityCode")
|
||||
}
|
||||
fragment OrderCreatedEventSubscription on Event {
|
||||
__typename
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
45
apps/taxes/src/modules/avatax/avatax-entity-type-matcher.ts
Normal file
45
apps/taxes/src/modules/avatax/avatax-entity-type-matcher.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -106,6 +106,7 @@ const defaultTaxBase: TaxBase = {
|
|||
},
|
||||
],
|
||||
sourceObject: {
|
||||
avataxEntityCode: null,
|
||||
user: {
|
||||
id: "VXNlcjoyMDg0NTEwNDEw",
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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. */,
|
||||
|
|
|
@ -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 />
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -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={
|
||||
<>
|
||||
<Text as="p" marginBottom={4}>
|
||||
Saleor offers two ways of calculating taxes: flat or dynamic rates.
|
||||
<br />
|
||||
<br />
|
||||
</Text>
|
||||
<Text as="p" marginBottom={4}>
|
||||
Taxes App leverages the dynamic rates by delegating the tax calculation to third-party
|
||||
services.
|
||||
<br />
|
||||
<br />
|
||||
</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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
|
|
|
@ -8,11 +8,13 @@ export const TaxJarInstructions = () => {
|
|||
title={"TaxJar Configuration"}
|
||||
description={
|
||||
<>
|
||||
<Text as="p" marginBottom={4}>
|
||||
The form consists of two sections: <i>Credentials</i> and <i>Address</i>.
|
||||
<br />
|
||||
<br />
|
||||
</Text>
|
||||
<Text as="p">
|
||||
<i>Credentials</i> will fail if:
|
||||
<Box as="ol" margin={0}>
|
||||
</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 "sandbox mode" setting.</Text>
|
||||
</li>
|
||||
</Box>
|
||||
<br />
|
||||
<br />
|
||||
<Text as="p" marginBottom={4}>
|
||||
<i>Address</i> will fail if:
|
||||
<Box as="ol" margin={0}>
|
||||
</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 />
|
||||
<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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
Loading…
Reference in a new issue