feat: add taxes app (#223)

* feat:  add taxes app

* chore: 🔥 remove files, use monorepo ones instead

* chore:  add all env vars to .env.example

* refactor: 🔧 use saleor eslint config

* build: ⬆️ app-sdk, remove vercel from apl

* refactor: ♻️ remove providers/index, infer taxProviders from providerConfig

* refactor: ♻️ use tuples to use objects

* refactor: 🚚 move types to taxes module

* refactor: ♻️ tax-prepare-data -> tax-line-resolver

* refactor: get isInFrame from apps-shared

* build: ⬆️ next

* feat:  add appRegister allowlist

* Update apps/taxes/src/pages/api/manifest.ts

Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>

* Update apps/taxes/src/pages/api/manifest.ts

Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>

* chore: 💡 improve comments

* refactor: 🔥 app-dashboard-link

* docs: 📝 add taxes to readme

* refactor: 🔥 app-main-bar

* refactor: ♻️ align saleor-app.ts with the rest

* refactor: ♻️ use defaultValues

* chore: ♻️ misc

---------

Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>
This commit is contained in:
Adrian Pilarczyk 2023-03-02 12:01:17 +01:00 committed by GitHub
parent 62ddd41094
commit 9f0dd813a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
115 changed files with 39534 additions and 20 deletions

View file

@ -43,6 +43,7 @@ In the `apps` folder, you will find the following applications:
- [klaviyo](./apps/klaviyo) - send Saleor events to Klaviyo, where you can notify the customers. - [klaviyo](./apps/klaviyo) - send Saleor events to Klaviyo, where you can notify the customers.
- [search](./apps/search) - connect Saleor with search engines. - [search](./apps/search) - connect Saleor with search engines.
- [slack](./apps/slack) - get notifications on Slack channel from Saleor events. - [slack](./apps/slack) - get notifications on Slack channel from Saleor events.
- [taxes](./apps/taxes) - calculate order and checkout taxes using external services.
## Development ## Development

10
apps/taxes/.env.example Normal file
View file

@ -0,0 +1,10 @@
#"fatal" | "error" | "warn" | "info" | "debug" | "trace"
APL=
APP_DEBUG=info
NODE_ENV=
SECRET_KEY=
PORT=
VERCEL_URL=
REST_APL_ENDPOINT=
REST_APL_TOKEN=
ALLOWED_DOMAIN_PATTERN=

4
apps/taxes/.eslintrc Normal file
View file

@ -0,0 +1,4 @@
{
"root": true,
"extends": ["saleor"]
}

20
apps/taxes/.graphqlrc.yml Normal file
View file

@ -0,0 +1,20 @@
schema: graphql/schema.graphql
documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx]
extensions:
codegen:
overwrite: true
generates:
generated/graphql.ts:
config:
dedupeFragments: true
plugins:
- typescript
- typescript-operations
- urql-introspection
- typescript-urql:
documentVariablePrefix: "Untyped"
fragmentVariablePrefix: "Untyped"
- typed-document-node
generated/schema.graphql:
plugins:
- schema-ast

1
apps/taxes/.npmrc Normal file
View file

@ -0,0 +1 @@
strict-peer-dependencies=false

40
apps/taxes/README.md Normal file
View file

@ -0,0 +1,40 @@
![Saleor App Taxes Hub](https://user-images.githubusercontent.com/249912/71523206-4e45f800-28c8-11ea-84ba-345a9bfc998a.png)
<div align="center">
<h1>Saleor App Taxes Hub</h1>
</div>
<div align="center">
<p>Hub for configuring taxes in Saleor using different providers.</p>
</div>
<div align="center">
<a href="https://saleor.io/">🏠 Website</a>
<span></span>
<a href="https://docs.saleor.io/docs/3.x/">📚 Docs</a>
<span></span>
<a href="https://saleor.io/blog/">📰 Blog</a>
<span></span>
<a href="https://twitter.com/getsaleor">🐦 Twitter</a>
</div>
<div align="center">
<a href="https://githubbox.com/saleor/saleor-app-template">🔎 Explore Code</a>
</div>
## About
### What is Saleor App Taxes Hub
Taxes App is a hub for configuring taxes in Saleor using different providers.
Integrates with:
- Avatax
- TaxJar
## Development
1. Install the app.
2. Go to _Configuration_ -> _Taxes_. In the "Select the method of tax calculation" select "Use tax app". Save.
3. To trigger the webhook, go to _Orders_, create a draft order with customer, products, shipping address, shipping method. The taxes will recalculate on each address change.

View file

@ -0,0 +1,10 @@
fragment AddressFragment on Address {
streetAddress1
streetAddress2
city
countryArea
postalCode
country {
code
}
}

View file

@ -0,0 +1,8 @@
fragment CalculateTaxesEvent on Event {
__typename
... on CalculateTaxes {
taxBase {
...TaxBase
}
}
}

View file

@ -0,0 +1,5 @@
fragment Channel on Channel {
id
name
slug
}

View file

@ -0,0 +1,69 @@
fragment TaxBaseLine on TaxableObjectLine {
chargeTaxes
sourceLine {
__typename
... on CheckoutLine {
id
productVariant: variant {
id
product {
metafield(key: "taxjar_tax_code")
productType {
metafield(key: "taxjar_tax_code")
}
}
}
}
... on OrderLine {
id
variant {
id
product {
metafield(key: "taxjar_tax_code")
productType {
metafield(key: "taxjar_tax_code")
}
}
}
}
}
quantity
unitPrice {
amount
}
totalPrice {
amount
}
}
fragment TaxDiscount on TaxableObjectDiscount {
name
amount {
amount
}
}
fragment TaxBase on TaxableObject {
currency
channel {
slug
}
discounts {
...TaxDiscount
}
address {
streetAddress1
streetAddress2
city
country {
code
}
countryArea
postalCode
}
shippingPrice {
amount
}
lines {
...TaxBaseLine
}
}

View file

@ -0,0 +1,10 @@
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
updatePrivateMetadata(id: $id, input: $input) {
item {
privateMetadata {
key
value
}
}
}
}

View file

@ -0,0 +1,9 @@
query FetchAppDetails {
app {
id
privateMetadata {
key
value
}
}
}

View file

@ -0,0 +1,6 @@
query FetchAppMetafields($keys: [String!]) {
app {
id
privateMetafields(keys: $keys)
}
}

View file

@ -0,0 +1,11 @@
query Channel($id: ID!) {
channel(id: $id) {
...Channel
}
}
query FetchChannels {
channels {
...Channel
}
}

View file

@ -0,0 +1,51 @@
query TaxConfigurationsList(
$before: String
$after: String
$first: Int
$last: Int
$filter: TaxConfigurationFilterInput
) {
taxConfigurations(before: $before, after: $after, first: $first, last: $last, filter: $filter) {
edges {
node {
...TaxConfiguration
__typename
}
__typename
}
__typename
}
}
fragment TaxConfiguration on TaxConfiguration {
id
channel {
...Channel
}
displayGrossPrices
pricesEnteredWithTax
chargeTaxes
taxCalculationStrategy
countries {
...TaxConfigurationPerCountry
__typename
}
__typename
}
fragment TaxConfigurationPerCountry on TaxConfigurationPerCountry {
country {
...CountryWithCode
__typename
}
chargeTaxes
taxCalculationStrategy
displayGrossPrices
__typename
}
fragment CountryWithCode on CountryDisplay {
country
code
__typename
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
subscription CalculateTaxes {
event {
...CalculateTaxesEvent
}
}

View file

@ -0,0 +1,60 @@
fragment OrderLineFragment on OrderLine {
productSku
productName
quantity
unitPrice {
net {
amount
}
}
totalPrice {
tax {
amount
}
}
}
fragment OrderSubscription on Order {
id
userEmail
created
channel {
id
slug
}
shippingAddress {
...AddressFragment
}
billingAddress {
...AddressFragment
}
total {
net {
amount
}
tax {
amount
}
}
shippingPrice {
net {
amount
}
}
lines {
...OrderLineFragment
}
}
fragment OrderCreatedEventSubscription on Event {
__typename
... on OrderCreated {
order {
...OrderSubscription
}
}
}
subscription OrderCreatedSubscription {
event {
...OrderCreatedEventSubscription
}
}

5
apps/taxes/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

10
apps/taxes/next.config.js Normal file
View file

@ -0,0 +1,10 @@
/**
* @type {import('next').NextConfig}
*/
module.exports = {
reactStrictMode: true,
experimental: {
esmExternals: true,
},
transpilePackages: ["@saleor/apps-shared"],
};

73
apps/taxes/package.json Normal file
View file

@ -0,0 +1,73 @@
{
"name": "saleor-app-taxes",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
"build": "pnpm generate && next build",
"start": "next start",
"lint": "next lint",
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"generate": "graphql-codegen",
"test": "vitest"
},
"saleor": {
"schemaVersion": "3.10"
},
"dependencies": {
"@hookform/resolvers": "^2.9.10",
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
"@material-ui/lab": "4.0.0-alpha.61",
"@saleor/app-sdk": "0.33.0",
"@saleor/apps-shared": "workspace:*",
"@saleor/macaw-ui": "^0.7.2",
"@tanstack/react-query": "^4.19.1",
"@trpc/client": "^10.9.0",
"@trpc/next": "^10.9.0",
"@trpc/react-query": "^10.9.0",
"@trpc/server": "^10.9.0",
"@urql/exchange-auth": "^1.0.0",
"@urql/exchange-multipart-fetch": "^1.0.1",
"avatax": "^23.2.0",
"clsx": "^1.2.1",
"graphql": "^16.6.0",
"graphql-tag": "^2.12.6",
"jotai": "^2.0.0",
"jsdom": "^20.0.3",
"next": "13.2.3",
"pino": "^8.8.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.42.1",
"taxjar": "^4.0.1",
"urql": "^3.0.3",
"usehooks-ts": "^2.9.1",
"vite": "^4.0.1",
"vitest": "^0.25.8",
"zod": "^3.20.2"
},
"devDependencies": {
"@graphql-codegen/cli": "2.13.3",
"@graphql-codegen/introspection": "2.2.1",
"@graphql-codegen/typed-document-node": "^2.3.3",
"@graphql-codegen/typescript": "2.7.3",
"@graphql-codegen/typescript-operations": "2.5.3",
"@graphql-codegen/typescript-urql": "^3.7.0",
"@graphql-codegen/urql-introspection": "2.2.1",
"@graphql-typed-document-node/core": "^3.1.1",
"@testing-library/react": "^13.4.0",
"@testing-library/react-hooks": "^8.0.1",
"@types/node": "^18.8.1",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^3.0.0",
"eslint": "8.25.0",
"eslint-config-next": "12.3.1",
"eslint-config-prettier": "^8.5.0",
"eslint-config-saleor": "workspace:*",
"pino-pretty": "^9.1.1",
"prettier": "^2.7.1",
"typescript": "4.8.4"
}
}

7633
apps/taxes/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

36
apps/taxes/saleor-app.ts Normal file
View file

@ -0,0 +1,36 @@
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
import { APL, FileAPL, SaleorCloudAPL, UpstashAPL } from "@saleor/app-sdk/APL";
/**
* By default auth data are stored in the `.auth-data.json` (FileAPL).
* For multi-tenant applications and deployments please use UpstashAPL.
*
* To read more about storing auth data, read the
* [APL documentation](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)
*/
export let apl: APL;
switch (process.env.APL) {
case "upstash":
// Require `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables
apl = new UpstashAPL();
break;
case "saleor-cloud": {
if (!process.env.REST_APL_ENDPOINT || !process.env.REST_APL_TOKEN) {
throw new Error("Rest APL is not configured - missing env variables. Check saleor-app.ts");
}
apl = new SaleorCloudAPL({
resourceUrl: process.env.REST_APL_ENDPOINT,
token: process.env.REST_APL_TOKEN,
});
break;
}
default:
apl = new FileAPL();
}
export const saleorApp = new SaleorApp({
apl,
});

View file

@ -0,0 +1,9 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M27.5 6.66667C27.5 5.47005 26.53 4.5 25.3333 4.5H6.66667C5.47005 4.5 4.5 5.47005 4.5 6.66667V21.3333C4.5 22.53 5.47005 23.5 6.66667 23.5H10.6667C11.1269 23.5 11.5 23.1269 11.5 22.6667C11.5 22.2064 11.1269 21.8333 10.6667 21.8333H6.66667C6.39053 21.8333 6.16667 21.6095 6.16667 21.3333V6.66667C6.16667 6.39052 6.39053 6.16667 6.66667 6.16667H25.3333C25.6095 6.16667 25.8333 6.39053 25.8333 6.66667L25.8333 9.1875C25.8333 9.64774 26.2064 10.0208 26.6667 10.0208C27.1269 10.0208 27.5 9.64774 27.5 9.1875L27.5 6.66667Z" fill="white"/>
<path d="M10.6667 9.33333C10.6667 10.0697 10.0697 10.6667 9.33333 10.6667C8.59695 10.6667 8 10.0697 8 9.33333C8 8.59695 8.59695 8 9.33333 8C10.0697 8 10.6667 8.59695 10.6667 9.33333Z" fill="white"/>
<path d="M13.3333 10.6667C14.0697 10.6667 14.6667 10.0697 14.6667 9.33333C14.6667 8.59695 14.0697 8 13.3333 8C12.597 8 12 8.59695 12 9.33333C12 10.0697 12.597 10.6667 13.3333 10.6667Z" fill="white"/>
<path d="M19.3333 19.3333C20.0697 19.3333 20.6667 18.7364 20.6667 18C20.6667 17.2636 20.0697 16.6667 19.3333 16.6667C18.597 16.6667 18 17.2636 18 18C18 18.7364 18.597 19.3333 19.3333 19.3333Z" fill="white"/>
<path d="M26 23.3333C26 24.0697 25.403 24.6667 24.6667 24.6667C23.9303 24.6667 23.3333 24.0697 23.3333 23.3333C23.3333 22.597 23.9303 22 24.6667 22C25.403 22 26 22.597 26 23.3333Z" fill="white"/>
<path d="M19.9226 23.9226L25.2559 18.5893C25.5814 18.2638 25.5814 17.7362 25.2559 17.4107C24.9305 17.0853 24.4028 17.0853 24.0774 17.4107L18.7441 22.7441C18.4186 23.0695 18.4186 23.5972 18.7441 23.9226C19.0695 24.248 19.5972 24.248 19.9226 23.9226Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.8333 20.6667C13.8333 16.1563 17.4897 12.5 22 12.5C26.5103 12.5 30.1667 16.1563 30.1667 20.6667C30.1667 25.177 26.5103 28.8333 22 28.8333C17.4897 28.8333 13.8333 25.177 13.8333 20.6667ZM22 14.1667C18.4101 14.1667 15.5 17.0768 15.5 20.6667C15.5 24.2565 18.4101 27.1667 22 27.1667C25.5898 27.1667 28.5 24.2565 28.5 20.6667C28.5 17.0768 25.5898 14.1667 22 14.1667Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.0311 16.7003C12.03 16.7038 11.0943 15.5566 10.5477 14.9375L9.77344 16.7635C10.8866 18.4222 11.6224 19.8479 12.0262 20.5322C12.8161 19.1954 14.7411 15.226 18.7304 11.5125L18.2901 10.4746C15.7002 12.3888 13.4946 14.835 12.0311 16.7003Z" fill="#059BD2"/>
<path d="M17.8392 21.0726H20.9993L18.2114 14.4004C17.3993 15.2643 16.6944 16.1188 16.084 16.933L17.8392 21.0726Z" fill="#FC6600"/>
<path d="M13.4218 2.93945H13.4024H10.6489H10.6309L3.05273 21.0742H6.21315L11.693 8.14952L11.9524 7.44813H12.1L12.3593 8.14952L13.9829 11.9801C14.7062 11.261 15.4916 10.5478 16.3215 9.87834L13.4218 2.93945Z" fill="#FC6600"/>
</svg>

After

Width:  |  Height:  |  Size: 723 B

View file

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 297 KiB

View file

@ -0,0 +1,18 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
export const useAppRedirect = () => {
const { appBridge } = useAppBridge();
const redirectToPath = (href: string) => {
return appBridge?.dispatch({
type: "redirect",
payload: {
newContext: false,
actionId: "redirect_from_tax_app",
to: href,
},
});
};
return { redirect: redirectToPath };
};

View file

@ -0,0 +1,45 @@
import { AuthConfig, authExchange } from "@urql/exchange-auth";
import { cacheExchange, createClient as urqlCreateClient, dedupExchange } from "urql";
import { multipartFetchExchange } from "@urql/exchange-multipart-fetch";
interface IAuthState {
token: string;
}
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
urqlCreateClient({
url,
exchanges: [
dedupExchange,
cacheExchange,
authExchange<IAuthState>({
addAuthToOperation: ({ authState, operation }) => {
if (!authState || !authState?.token) {
return operation;
}
const fetchOptions =
typeof operation.context.fetchOptions === "function"
? operation.context.fetchOptions()
: operation.context.fetchOptions || {};
return {
...operation,
context: {
...operation.context,
fetchOptions: {
...fetchOptions,
headers: {
...fetchOptions.headers,
"Authorization-Bearer": authState.token,
},
},
},
};
},
getAuth,
}),
multipartFetchExchange,
],
});

View file

@ -0,0 +1,16 @@
import pino from "pino";
export const logger = pino({
level: process.env.APP_DEBUG ?? "silent",
transport:
process.env.NODE_ENV === "development"
? {
target: "pino-pretty",
options: {
colorize: true,
},
}
: undefined,
});
export const createLogger = logger.child.bind(logger);

View file

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

View file

@ -0,0 +1,3 @@
export * from "./saleor-app-sdk-copy";
export * from "./saleor-sync-webhook";
export * from "./saleor-webhook";

View file

@ -0,0 +1,49 @@
import { APL, AuthData } from "@saleor/app-sdk/APL";
import { SyncWebhookEventType } from "@saleor/app-sdk/types";
import { ASTNode, print } from "graphql";
// ! start: borrowed from saleor-app-sdk
interface WebhookManifestConfigurationBase {
name?: string;
webhookPath: string;
syncEvent: SyncWebhookEventType;
isActive?: boolean;
apl: APL;
}
interface WebhookManifestConfigurationWithAst extends WebhookManifestConfigurationBase {
subscriptionQueryAst: ASTNode;
}
interface WebhookManifestConfigurationWithQuery extends WebhookManifestConfigurationBase {
query: string;
}
export type WebhookManifestConfiguration =
| WebhookManifestConfigurationWithAst
| WebhookManifestConfigurationWithQuery;
export const gqlAstToString = (ast: ASTNode) =>
print(ast) // convert AST to string
.replaceAll(/\n*/g, "") // remove new lines
.replaceAll(/\s{2,}/g, " ") // remove unnecessary multiple spaces
.trim(); // remove whitespace from beginning and end
export type WebhookContext<T> = {
baseUrl: string;
event: string;
payload: T;
authData: AuthData;
};
export const toStringOrUndefined = (value: string | string[] | undefined) =>
value ? value.toString() : undefined;
export const SALEOR_API_URL_HEADER = "saleor-api-url";
export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => {
const { host, "x-forwarded-proto": protocol = "http" } = headers;
return `${protocol}://${host}`;
};
// ! end: borrowed from saleor-app-sdk

View file

@ -0,0 +1,89 @@
import { APL } from "@saleor/app-sdk/APL";
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { SyncWebhookEventType, WebhookManifest } from "@saleor/app-sdk/types";
import { ASTNode } from "graphql";
import { NextApiHandler } from "next";
// todo: move to app-sdk
import {
getBaseUrl,
gqlAstToString,
SALEOR_API_URL_HEADER,
toStringOrUndefined,
WebhookContext,
WebhookManifestConfiguration,
} from "./saleor-app-sdk-copy";
import { SaleorWebhook } from "./saleor-webhook";
export class SaleorSyncWebhook<TPayload = any, TResponse = any>
implements SaleorWebhook<TPayload, TResponse>
{
name: string;
webhookPath: string;
event: SyncWebhookEventType;
apl: APL;
subscriptionQueryAst?: ASTNode;
query?: string;
isActive?: boolean;
constructor(configuration: WebhookManifestConfiguration) {
const { name, webhookPath, syncEvent, apl, isActive = true } = configuration;
this.name = name || `${syncEvent} webhook`;
if ("query" in configuration) {
this.query = configuration.query;
}
if ("subscriptionQueryAst" in configuration) {
this.subscriptionQueryAst = configuration.subscriptionQueryAst;
}
if (!this.subscriptionQueryAst && !this.query) {
// todo: replace with WebhookError in sdk
throw new Error(
"Need to specify `subscriptionQueryAst` or `query` to create webhook subscription"
);
}
this.webhookPath = webhookPath;
this.event = syncEvent;
this.isActive = isActive;
this.apl = apl;
}
getTargetUrl(baseUrl: string): string {
return new URL(this.webhookPath, baseUrl).href;
}
getWebhookManifest(baseUrl: string): WebhookManifest {
return {
name: this.name,
targetUrl: this.getTargetUrl(baseUrl),
syncEvents: [this.event],
isActive: this.isActive,
// Query can be provided as plaintext..
...(this.query && { query: this.query }),
// ...GQL AST which has to be stringified..
...(this.subscriptionQueryAst && { query: gqlAstToString(this.subscriptionQueryAst) }),
// or no query at all. In such case default webhook payload will be sent
};
}
createHandler(handlerFn: NextWebhookApiHandler<TPayload, TResponse>): NextApiHandler<TResponse> {
return async (req, res) => {
const saleorApiUrl = toStringOrUndefined(req.headers[SALEOR_API_URL_HEADER]);
if (!saleorApiUrl) {
return res.status(400).end();
}
const authData = await this.apl.get(saleorApiUrl);
if (!authData) {
return res.status(401).end();
}
const baseUrl = getBaseUrl(req.headers);
const context: WebhookContext<TPayload> = {
authData,
baseUrl,
event: this.event,
payload: req.body,
};
return handlerFn(req, res, context);
};
}
}

View file

@ -0,0 +1,19 @@
import { APL } from "@saleor/app-sdk/APL";
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { SyncWebhookEventType, WebhookManifest } from "@saleor/app-sdk/types";
import { ASTNode } from "graphql";
import { NextApiHandler } from "next";
export interface SaleorWebhook<TPayload = any, TResponse = any> {
name: string;
webhookPath: string;
event: SyncWebhookEventType;
apl: APL;
subscriptionQueryAst?: ASTNode;
query?: string;
isActive?: boolean;
getTargetUrl(baseUrl: string): string;
getWebhookManifest(baseUrl: string): WebhookManifest;
createHandler(handlerFn: NextWebhookApiHandler<TPayload, TResponse>): NextApiHandler<TResponse>;
}

View file

@ -0,0 +1,81 @@
import { z } from "zod";
import { CalculateTaxesEventFragment } from "../../../generated/graphql";
export type ExpectedWebhookPayload = Extract<
CalculateTaxesEventFragment,
{ __typename: "CalculateTaxes" }
>;
const taxDiscountSchema = z.object({
name: z.string(),
amount: z.object({
amount: z.number(),
}),
});
const checkoutBaseLineSchema = z.object({
__typename: z.literal("CheckoutLine"),
id: z.string(),
productVariant: z.object({
id: z.string(),
product: z.object({
metafield: z.string().nullable(),
productType: z.object({
metafield: z.string().nullable(),
}),
}),
}),
});
const orderBaseLineSchema = z.object({
__typename: z.literal("OrderLine"),
id: z.string(),
variant: z.object({
id: z.string(),
product: z.object({
metafield: z.string().nullable(),
productType: z.object({
metafield: z.string().nullable(),
}),
}),
}),
});
const taxAddressSchema = z.object({
streetAddress1: z.string(),
streetAddress2: z.string(),
city: z.string(),
country: z.object({
code: z.string(),
}),
countryArea: z.string(),
postalCode: z.string(),
});
const taxBaseLineSchema = z.object({
chargeTaxes: z.boolean(),
quantity: z.number(),
unitPrice: z.object({
amount: z.number(),
}),
totalPrice: z.object({
amount: z.number(),
}),
sourceLine: z.union([checkoutBaseLineSchema, orderBaseLineSchema]),
});
export const calculateTaxesPayloadSchema: z.ZodType<ExpectedWebhookPayload> = z.object({
__typename: z.literal("CalculateTaxes"),
taxBase: z.object({
currency: z.string(),
channel: z.object({
slug: z.string(),
}),
__typename: z.literal("TaxableObject").optional(),
discounts: z.array(taxDiscountSchema),
address: taxAddressSchema,
shippingPrice: z.object({
amount: z.number(),
}),
lines: z.array(taxBaseLineSchema).min(1),
}),
});

View file

@ -0,0 +1,104 @@
import { NextApiRequest, NextApiResponse } from "next";
import { ZodError } from "zod";
import { CalculateTaxes } from "../../../generated/graphql";
import { ChannelConfig } from "../../modules/channels-configuration/channels-config";
import { GetChannelsConfigurationService } from "../../modules/channels-configuration/get-channels-configuration.service";
import { GetProvidersConfigurationService } from "../../modules/providers-configuration/get-providers-configuration.service";
import { ProviderConfig } from "../../modules/providers-configuration/providers-config";
import { defaultTaxesResponse } from "../../modules/taxes/defaults";
import { TaxProviderError } from "../../modules/taxes/tax-provider-error";
import { createClient } from "../graphql";
import { createLogger } from "../logger";
import { WebhookContext } from "./saleor-app-sdk";
import { calculateTaxesPayloadSchema, ExpectedWebhookPayload } from "./schema";
import { ResponseTaxPayload } from "../../modules/taxes/types";
export const withTaxesWebhook =
(
handler: (
payload: ExpectedWebhookPayload,
config: {
provider: ProviderConfig;
channel: ChannelConfig;
},
response: NextApiResponse<ResponseTaxPayload>
) => Promise<void>
) =>
async (req: NextApiRequest, res: NextApiResponse, context: WebhookContext<CalculateTaxes>) => {
const logger = createLogger({ event: context.event });
const { authData, payload } = context;
logger.info("Webhook triggered. withTaxesWebhook called");
logger.info({ payload }, "Payload received");
if (!authData) {
logger.error("Auth data not found");
logger.info(defaultTaxesResponse, "Responding with the defaultTaxesResponse");
return res.status(200).json(defaultTaxesResponse);
}
logger.info("Parsing payload...");
const validation = calculateTaxesPayloadSchema.safeParse(req.body);
if (!validation.success) {
logger.error({ error: validation.error.message }, "Payload is invalid");
logger.info(defaultTaxesResponse, "Responding with the defaultTaxesResponse");
return res.status(200).json(defaultTaxesResponse);
}
const { data } = validation;
logger.info({ data }, "Payload is valid.");
try {
const { authData } = context;
const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
const providersConfig = await new GetProvidersConfigurationService({
saleorApiUrl: authData.saleorApiUrl,
apiClient: client,
}).getConfiguration();
const channelsConfig = await new GetChannelsConfigurationService({
saleorApiUrl: authData.saleorApiUrl,
apiClient: client,
}).getConfiguration();
logger.info({ providersConfig }, "Providers configuration returned");
const channelSlug = payload.taxBase.channel.slug;
const channelConfig = channelsConfig[channelSlug];
if (!channelConfig) {
logger.error(`Channel config not found for channel ${channelSlug}`);
logger.info(defaultTaxesResponse, "Responding with the defaultTaxesResponse");
return res.status(200).json(defaultTaxesResponse);
}
const providerInstance = providersConfig.find(
(instance) => instance.id === channelConfig.providerInstanceId
);
if (!providerInstance) {
logger.error(`Channel (${channelSlug}) providerInstanceId does not match any providers`);
logger.info(defaultTaxesResponse, "Responding with the defaultTaxesResponse");
return res.status(200).json(defaultTaxesResponse);
}
return handler(data, { provider: providerInstance, channel: channelConfig }, res);
} catch (error) {
// todo: improve error handling; currently instanceof zod is not working
if (error instanceof ZodError) {
logger.error({ message: error.message }, "Zod error");
}
if (error instanceof TaxProviderError) {
logger.error({ error }, "TaxProviderError");
} else {
logger.error({ error }, "Unknown error");
}
logger.info(defaultTaxesResponse, "Responding with the defaultTaxesResponse");
return res.status(200).json(defaultTaxesResponse);
}
};

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,68 @@
import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
import { Client } from "urql";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateAppMetadataDocument,
} from "../../../generated/graphql";
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
if (error) {
return [];
}
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
}
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
// to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
.toPromise();
if (idQueryError) {
throw new Error(
"Could not fetch the app id. Please check if auth data for the client are valid."
);
}
const appId = idQueryData?.app?.id;
if (!appId) {
throw new Error("Could not fetch the app ID");
}
const { error: mutationError, data: mutationData } = await client
.mutation(UpdateAppMetadataDocument, {
id: appId,
input: metadata,
})
.toPromise();
if (mutationError) {
throw new Error(`Mutation error: ${mutationError.message}`);
}
return (
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
key: md.key,
value: md.value,
})) || []
);
}
export const createSettingsManager = (client: Client) => {
// EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
// We recommend it for production, because all values are encrypted.
// If your use case require plain text values, you can use MetadataManager.
return new EncryptedMetadataManager({
// Secret key should be randomly created for production and set as environment variable
encryptionKey: process.env.SECRET_KEY!,
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutateMetadata(client, metadata),
});
};

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,42 @@
import { Client } from "urql";
import { logger as pinoLogger } from "../../lib/logger";
import { createSettingsManager } from "../app-configuration/metadata-manager";
import { ChannelsFetcher } from "../channels/channels-fetcher";
import { createDefaultChannelsConfig } from "./channels-config";
import { TaxChannelsConfigurator } from "./channels-configurator";
export class GetChannelsConfigurationService {
constructor(
private settings: {
apiClient: Client;
saleorApiUrl: string;
}
) {}
async getConfiguration() {
const logger = pinoLogger.child({
service: "GetChannelsConfigurationService",
saleorApiUrl: this.settings.saleorApiUrl,
});
const { saleorApiUrl, apiClient } = this.settings;
const taxConfigurator = new TaxChannelsConfigurator(
createSettingsManager(apiClient),
saleorApiUrl
);
const channelsFetcher = new ChannelsFetcher(apiClient);
const channels = await channelsFetcher.fetchChannels();
logger.info({ channels }, "Fetched Saleor channels that use TAX_APP for tax calculation");
const defaultConfig = createDefaultChannelsConfig(channels ?? []);
logger.info({ defaultConfig }, "Generated config from Saleor channels");
// todo: validate config
const appChannelsConfig = (await taxConfigurator.getConfig()) ?? null;
logger.debug(appChannelsConfig, "Retrieved channels config from Metadata");
return { ...defaultConfig, ...appChannelsConfig };
}
}

View file

@ -0,0 +1,24 @@
import { Client } from "urql";
import {
TaxConfigurationsListDocument,
TaxConfigurationsListQueryVariables,
} from "../../../generated/graphql";
export class ChannelsFetcher {
constructor(private client: Client) {}
fetchChannels() {
return this.client
.query(TaxConfigurationsListDocument, {
first: 10,
} as TaxConfigurationsListQueryVariables)
.toPromise()
.then((r) => {
return (
r.data?.taxConfigurations?.edges
.filter(({ node }) => node.taxCalculationStrategy === "TAX_APP")
.map(({ node }) => node.channel) ?? []
);
});
}
}

View file

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

View file

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

View file

@ -0,0 +1,158 @@
import { Grid, Typography } from "@material-ui/core";
import { Warning } from "@material-ui/icons";
import { Skeleton } from "@material-ui/lab";
import { Button, makeStyles } from "@saleor/macaw-ui";
import { PropsWithChildren } from "react";
import { useAppRedirect } from "../../../lib/app/redirect";
import { ProviderIcon } from "../../providers-configuration/ui/provider-icon";
import { providerConfig, TaxProviderName } from "../../taxes/providers/config";
import { useActiveTab, useChannelSlug, useInstanceId } from "../../taxes/tax-context";
import { trpcClient } from "../../trpc/trpc-client";
import { AppLink } from "../../ui/app-link";
import { AppPaper } from "../../ui/app-paper";
import { ChannelTaxProviderForm } from "./channel-tax-provider-form";
const useStyles = makeStyles((theme) => ({
centerWithGap: {
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
},
}));
const NoDataPlaceholder = ({
title,
children,
}: PropsWithChildren<{
title: string;
}>) => {
const styles = useStyles();
return (
<AppPaper>
<div>
<span className={styles.centerWithGap}>
<Typography component={"h3"} variant="h3">
{title}
</Typography>
<Warning />
</span>
<br />
{children}
</div>
</AppPaper>
);
};
const NoChannelPlaceholder = () => {
const { redirect } = useAppRedirect();
return (
<NoDataPlaceholder title={"Channels not found"}>
<Typography variant="body1">
For a channel to appear on this list, you need to configure it on the{" "}
<AppLink href="/taxes/channels">Tax Configuration</AppLink> page.
</Typography>
<br />
<Typography variant="body1">
By default, each channel will use <q>flat rates</q> as the tax calculation method. If you
want a channel to calculate taxes using the Tax App, you need to change the tax calculation
method to <b>Use tax app</b>.
</Typography>
<br />
<Button onClick={() => redirect("/taxes/channels")}>Go to Tax Configuration</Button>
</NoDataPlaceholder>
);
};
const NoProviderPlaceholder = () => {
const styles = useStyles();
const { setActiveTab } = useActiveTab();
const { setInstanceId } = useInstanceId();
return (
<NoDataPlaceholder title={"Tax providers not found"}>
<Typography variant="body1">
You need to set up at least one tax provider before you can configure a channel.
</Typography>
<br />
<Typography>
We currently support the following tax providers:
<ul>
{Object.entries(providerConfig).map(([provider, { label }]) => (
<Typography variant="body1" component={"li"} key={label}>
<span className={styles.centerWithGap}>
{label}
<ProviderIcon size={"medium"} provider={provider as TaxProviderName} />
</span>
</Typography>
))}
</ul>
</Typography>
<Button
onClick={() => {
setActiveTab("providers");
setInstanceId(null);
}}
>
Configure a tax provider
</Button>
</NoDataPlaceholder>
);
};
const ChannelTaxProviderSkeleton = () => {
return (
<AppPaper>
<Grid container spacing={2}>
<Grid item xs={12}>
<Skeleton variant="rect" width={"35%"} height={10} />
</Grid>
<Grid item xs={12}>
<Skeleton variant="rect" width={"100%"} height={30} />
</Grid>
<br />
<Grid item xs={12}>
<Skeleton variant="rect" width={"35%"} height={10} />
</Grid>
<Grid item xs={8}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
<Grid item xs={4}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
<Grid item xs={6}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
<Grid item xs={6}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
<Grid item xs={12}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
</Grid>
</AppPaper>
);
};
export const ChannelTaxProvider = () => {
const { channelSlug } = useChannelSlug();
const channels = trpcClient.channels.fetch.useQuery(undefined, {});
const providers = trpcClient.providersConfiguration.getAll.useQuery();
if (channels?.isFetching || providers?.isFetching) {
return <ChannelTaxProviderSkeleton />;
}
if (!channelSlug) {
return <NoChannelPlaceholder />;
}
if (!providers?.data?.length) {
return <NoProviderPlaceholder />;
}
return (
<AppPaper>
<ChannelTaxProviderForm />
</AppPaper>
);
};

View file

@ -0,0 +1,70 @@
import { makeStyles } from "@saleor/macaw-ui";
import {
OffsettedList,
OffsettedListBody,
OffsettedListHeader,
OffsettedListItem,
OffsettedListItemCell,
} from "@saleor/macaw-ui";
import clsx from "clsx";
import { ChannelFragment } from "../../../../generated/graphql";
const useStyles = makeStyles((theme) => {
return {
headerItem: {
height: "auto !important",
display: "grid",
gridTemplateColumns: "1fr",
},
listItem: {
cursor: "pointer",
height: "auto !important",
display: "grid",
gridTemplateColumns: "1fr",
},
listItemActive: {
border: `2px solid ${theme.palette.primary.main}`,
},
cellSlug: {
fontFamily: "monospace",
opacity: 0.8,
},
};
});
type Props = {
channels: ChannelFragment[];
activeChannelSlug: string;
onChannelClick(channelSlug: string): void;
};
export const ChannelsList = ({ channels, activeChannelSlug, onChannelClick }: Props) => {
const styles = useStyles();
return (
<OffsettedList gridTemplate={["1fr", "1fr"]}>
<OffsettedListHeader>
<OffsettedListItem className={styles.headerItem}>
<OffsettedListItemCell>Channel name</OffsettedListItemCell>
</OffsettedListItem>
</OffsettedListHeader>
<OffsettedListBody>
{channels.map((c) => {
return (
<OffsettedListItem
className={clsx(styles.listItem, {
[styles.listItemActive]: c.slug === activeChannelSlug,
})}
key={c.slug}
onClick={() => {
onChannelClick(c.slug);
}}
>
<OffsettedListItemCell>{c.name}</OffsettedListItemCell>
</OffsettedListItem>
);
})}
</OffsettedListBody>
</OffsettedList>
);
};

View file

@ -0,0 +1,56 @@
import { Grid } from "@material-ui/core";
import { Skeleton } from "@material-ui/lab";
import { useChannelSlug } from "../../taxes/tax-context";
import { trpcClient } from "../../trpc/trpc-client";
import { AppPaper } from "../../ui/app-paper";
import { ChannelsList } from "./channels-list";
const ChannelsSkeleton = () => {
return (
<AppPaper>
<Grid container spacing={2}>
<Grid item xs={12}>
<Skeleton variant="rect" width={"45%"} height={10} />
</Grid>
<Grid item xs={12}>
<Skeleton variant="rect" width={"100%"} height={30} />
</Grid>
</Grid>
</AppPaper>
);
};
export const Channels = () => {
const { channelSlug, setChannelSlug } = useChannelSlug();
const channels = trpcClient.channels.fetch.useQuery(undefined, {
onSuccess: (result) => {
if (result?.[0]) {
setChannelSlug(result?.[0].slug);
}
},
});
if (channels?.isFetching) {
return <ChannelsSkeleton />;
}
if (channels.error) {
return <div>Error. No channel available</div>;
}
if (channels.data?.length === 0) {
// empty space for grid
return <div></div>;
}
return (
<AppPaper>
<ChannelsList
channels={channels.data ?? []}
activeChannelSlug={channelSlug}
onChannelClick={(nextSlug) => setChannelSlug(nextSlug)}
/>
</AppPaper>
);
};

View file

@ -0,0 +1,43 @@
import { Client } from "urql";
import { logger as pinoLogger } from "../../lib/logger";
import { createSettingsManager } from "../app-configuration/metadata-manager";
import { createDefaultConfig, serverProvidersSchema } from "./providers-config";
import { TaxProvidersConfigurator } from "./providers-configurator";
export class GetProvidersConfigurationService {
constructor(
private settings: {
apiClient: Client;
saleorApiUrl: string;
}
) {}
async getConfiguration() {
const logger = pinoLogger.child({
service: "GetProvidersConfigurationService",
saleorApiUrl: this.settings.saleorApiUrl,
});
const { saleorApiUrl, apiClient } = this.settings;
const taxConfigurator = new TaxProvidersConfigurator(
createSettingsManager(apiClient),
saleorApiUrl
);
const savedProvidersConfig = (await taxConfigurator.getConfig()) ?? null;
const validation = serverProvidersSchema.safeParse(savedProvidersConfig);
logger.info({ validation }, "Config validated:");
if (validation.success) {
logger.info("App config is valid. Returning.");
return validation.data;
}
logger.info("App config not found in metadata. Will return default config.");
const defaultConfig = createDefaultConfig();
return defaultConfig;
}
}

View file

@ -0,0 +1,15 @@
import { z } from "zod";
import { providerSchema } from "./providers-config";
export const updateProviderInstanceInputSchema = z.object({
id: z.string(),
provider: providerSchema,
});
export const deleteProviderInstanceInputSchema = z.object({
id: z.string(),
});
export const createProviderInstanceInputSchema = z.object({
provider: providerSchema,
});

View file

@ -0,0 +1,24 @@
import { z } from "zod";
import {
avataxInstanceConfigSchema,
serverAvataxSchema,
} from "../taxes/providers/avatax/avatax-config";
import {
taxJarInstanceConfigSchema,
serverTaxJarSchema,
} from "../taxes/providers/taxjar/taxjar-config";
export const providerSchema = taxJarInstanceConfigSchema.or(avataxInstanceConfigSchema);
export const providersSchema = z.array(providerSchema.and(z.object({ id: z.string() })));
const serverProviderSchema = serverTaxJarSchema.or(serverAvataxSchema);
export const serverProvidersSchema = z.array(
serverProviderSchema.and(z.object({ id: z.string() }))
);
export type ProvidersConfig = z.infer<typeof providersSchema>;
export type ProviderConfig = z.infer<typeof providerSchema>;
export const defaultTaxProvidersConfig: ProvidersConfig = [];
export const createDefaultConfig = () => defaultTaxProvidersConfig;

View file

@ -0,0 +1,147 @@
import { TRPCError } from "@trpc/server";
import { logger as pinoLogger } from "../../lib/logger";
import { createId } from "../../lib/utils";
import { createSettingsManager } from "../app-configuration/metadata-manager";
import { ActiveTaxProvider } from "../taxes/active-tax-provider";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server";
import { GetProvidersConfigurationService } from "./get-providers-configuration.service";
import { ProvidersConfig } from "./providers-config";
import {
createProviderInstanceInputSchema,
deleteProviderInstanceInputSchema,
updateProviderInstanceInputSchema,
} from "./providers-config-input-schema";
import { TaxProvidersConfigurator } from "./providers-configurator";
export const providersConfigurationRouter = router({
getAll: protectedClientProcedure.query(async ({ ctx }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("providersConfigurationRouter.fetch called");
return new GetProvidersConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
}),
update: protectedClientProcedure
.input(updateProviderInstanceInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.info(input, "providersConfigurationRouter.update called with input:");
const currentProviders = await new GetProvidersConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
const provider = currentProviders.find((provider) => provider.id === input.id);
if (provider) {
const taxProvider = new ActiveTaxProvider(provider);
const validation = await taxProvider.validate();
if (validation && !validation.ok) {
logger.error(validation.error, "External validation failed.");
throw new TRPCError({
code: "BAD_REQUEST",
message: validation.error,
});
}
}
logger.info(currentProviders, "Fetched current providers:");
const taxProvidersConfigurator = new TaxProvidersConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const nextProviders: ProvidersConfig = currentProviders.map((provider) => {
if (provider.id === input.id) {
return {
...input.provider,
id: input.id,
};
}
return provider;
});
logger.info(nextProviders, "Will update providers with the following value:");
await taxProvidersConfigurator.setConfig(nextProviders);
return null;
}),
delete: protectedClientProcedure
.input(deleteProviderInstanceInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.info(input, "providersConfigurationRouter.delete called with input:");
const currentProviders = await new GetProvidersConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
logger.info(currentProviders, "Fetched current providers:");
const taxProvidersConfigurator = new TaxProvidersConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const nextProviders: ProvidersConfig = currentProviders.filter(
(provider) => provider.id !== input.id
);
logger.info(nextProviders, "Will update providers with the following value:");
await taxProvidersConfigurator.setConfig(nextProviders);
return null;
}),
create: protectedClientProcedure
.input(createProviderInstanceInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.info(input, "providersConfigurationRouter.create called with input:");
const currentProviders = await new GetProvidersConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
logger.info(currentProviders, "Fetched current providers:");
const taxProvidersConfigurator = new TaxProvidersConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const id = createId();
const provider = { ...input.provider, id };
const nextProviders: ProvidersConfig = [...currentProviders, provider];
if (provider) {
const taxProvider = new ActiveTaxProvider(provider);
const validation = await taxProvider.validate();
if (validation && !validation.ok) {
logger.error(validation.error, "External validation failed.");
throw new TRPCError({
code: "BAD_REQUEST",
message: validation.error,
});
}
}
logger.info(nextProviders, "Will update providers with the following value:");
await taxProvidersConfigurator.setConfig(nextProviders);
return { id };
}),
});

View file

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

View file

@ -0,0 +1,104 @@
import { FormControlLabel, Grid, Radio, RadioGroup, Typography } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
import React from "react";
import { AvataxConfiguration } from "../../taxes/providers/avatax/ui/avatax-configuration";
import { providerConfig, TaxProviderName } from "../../taxes/providers/config";
import { TaxJarConfiguration } from "../../taxes/providers/taxjar/ui/taxjar-configuration";
import { useInstanceId } from "../../taxes/tax-context";
import { trpcClient } from "../../trpc/trpc-client";
import { AppPaper } from "../../ui/app-paper";
import { ProviderIcon } from "./provider-icon";
const providersConfigurationComponent: Record<TaxProviderName, React.ComponentType> = {
taxjar: TaxJarConfiguration,
avatax: AvataxConfiguration,
};
const useStyles = makeStyles((theme) => ({
radioLabel: {
width: "100%",
padding: theme.spacing(1),
border: `1px solid ${theme.palette.divider}`,
"&:hover": {
backgroundColor:
theme.palette.type === "dark" ? theme.palette.primary.dark : theme.palette.grey[50],
},
},
gridItem: {
display: "flex",
justifyContent: "center",
},
radioLabelActive: {
backgroundColor:
theme.palette.type === "dark" ? theme.palette.primary.dark : theme.palette.grey[50],
},
iconWithLabel: {
display: "flex",
alignItems: "center",
flexDirection: "column",
gap: theme.spacing(1),
},
}));
export const Configuration = () => {
const [provider, setProvider] = React.useState<TaxProviderName>("taxjar");
const { instanceId } = useInstanceId();
const { data: providersConfigurationData } = trpcClient.providersConfiguration.getAll.useQuery();
const styles = useStyles();
React.useEffect(() => {
const instance = providersConfigurationData?.find((instance) => instance.id === instanceId);
setProvider(instance?.provider ?? "taxjar");
}, [instanceId, providersConfigurationData]);
const SelectedConfigurationForm = React.useMemo(
() => (provider ? providersConfigurationComponent[provider] : () => null),
[provider]
);
return (
<AppPaper>
{!instanceId && (
<Grid container spacing={2}>
<Grid item xs={12}>
<div className={styles.gridItem}>
<Typography component="h3" variant="h3">
Please select one of the providers:
</Typography>
</div>
</Grid>
<Grid item xs={12}>
<RadioGroup
value={provider ?? ""}
onChange={(e) => setProvider(e.target.value as TaxProviderName)}
>
<Grid container justifyContent="center">
{Object.entries(providerConfig).map(([name, config]) => (
<Grid className={styles.gridItem} item xs={6} key={name}>
<FormControlLabel
className={
provider === name
? `${styles.radioLabelActive} ${styles.radioLabel}`
: styles.radioLabel
}
control={<Radio style={{ display: "none" }} name="provider" value={name} />}
label={
<div className={styles.iconWithLabel}>
<ProviderIcon size={"xlarge"} provider={name as TaxProviderName} />
<Typography variant="body1">{config.label}</Typography>
</div>
}
labelPlacement="top"
aria-label={config.label}
/>
</Grid>
))}
</Grid>
</RadioGroup>
</Grid>
</Grid>
)}
<SelectedConfigurationForm />
</AppPaper>
);
};

View file

@ -0,0 +1,44 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
} from "@material-ui/core";
import { Button, makeStyles } from "@saleor/macaw-ui";
type DeleteProviderDialogProps = {
isOpen: boolean;
onClose: () => void;
onCancel: () => void;
onConfirm: () => void;
};
const useStyles = makeStyles((theme) => ({
actions: {
display: "flex",
gap: theme.spacing(1),
},
}));
export const DeleteProviderDialog = (p: DeleteProviderDialogProps) => {
const styles = useStyles();
return (
<Dialog open={p.isOpen} onClose={p.onClose}>
<DialogTitle>Delete provider instance?</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete this provider instance? This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<div className={styles.actions}>
<Button onClick={p.onCancel}>Cancel</Button>
<Button variant="primary" onClick={p.onConfirm}>
Confirm
</Button>
</div>
</DialogActions>
</Dialog>
);
};

View file

@ -0,0 +1,24 @@
import Image, { ImageProps } from "next/image";
import { providerConfig, TaxProviderName } from "../../taxes/providers/config";
type Size = "small" | "medium" | "large" | "xlarge";
const sizes: Record<Size, number> = {
small: 16,
medium: 24,
large: 32,
xlarge: 48,
};
type ProviderIconProps = {
provider: TaxProviderName;
size?: Size;
} & Omit<ImageProps, "src" | "height" | "width" | "alt">;
export const ProviderIcon = ({ provider, size = "medium", ...props }: ProviderIconProps) => {
const { icon, label } = providerConfig[provider];
const matchedSize = sizes[size];
return (
<Image src={icon} alt={`${label} icon`} width={matchedSize} height={matchedSize} {...props} />
);
};

View file

@ -0,0 +1,70 @@
import {
makeStyles,
OffsettedList,
OffsettedListBody,
OffsettedListHeader,
OffsettedListItem,
OffsettedListItemCell,
} from "@saleor/macaw-ui";
import clsx from "clsx";
import { useInstanceId } from "../../taxes/tax-context";
import { trpcClient } from "../../trpc/trpc-client";
import { AppPaper } from "../../ui/app-paper";
import { ProviderIcon } from "./provider-icon";
const useStyles = makeStyles((theme) => {
return {
headerItem: {
height: "auto !important",
display: "grid",
gridTemplateColumns: "1fr",
},
listItem: {
cursor: "pointer",
height: "auto !important",
display: "grid",
gridTemplateColumns: "1fr",
},
listItemActive: {
border: `2px solid ${theme.palette.primary.main}`,
},
cell: {
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
},
};
});
export const TaxProvidersInstancesList = () => {
const styles = useStyles();
const { instanceId, setInstanceId } = useInstanceId();
const { data: providersConfigurationData } = trpcClient.providersConfiguration.getAll.useQuery();
const instances = providersConfigurationData ?? [];
return (
<AppPaper>
<OffsettedList gridTemplate={["1fr", "1fr"]}>
<OffsettedListHeader>
<OffsettedListItem className={styles.headerItem}>
<OffsettedListItemCell>Tax provider list</OffsettedListItemCell>
</OffsettedListItem>
</OffsettedListHeader>
<OffsettedListBody>
{instances.map((instance) => (
<OffsettedListItem
onClick={() => setInstanceId(instance.id)}
className={clsx(styles.listItem, instance.id === instanceId && styles.listItemActive)}
key={instance.id}
>
<OffsettedListItemCell className={styles.cell}>
{instance.name}
<ProviderIcon size="medium" provider={instance.provider} />
</OffsettedListItemCell>
</OffsettedListItem>
))}
</OffsettedListBody>
</OffsettedList>
</AppPaper>
);
};

View file

@ -0,0 +1,74 @@
import { Grid } from "@material-ui/core";
import { Add } from "@material-ui/icons";
import { Skeleton } from "@material-ui/lab";
import { Button, makeStyles } from "@saleor/macaw-ui";
import { useInstanceId } from "../../taxes/tax-context";
import { trpcClient } from "../../trpc/trpc-client";
import { AppPaper } from "../../ui/app-paper";
import { TaxProvidersInstancesList } from "./providers-instances-list";
const useStyles = makeStyles((theme) => {
return {
button: {
padding: theme.spacing(1, 2),
justifyContent: "flex-start",
},
};
});
const ProvidersSkeleton = () => {
return (
<AppPaper>
<Grid container spacing={2}>
<Grid item xs={12}>
<Skeleton variant="rect" width={"45%"} height={10} />
</Grid>
<Grid item xs={12}>
<Skeleton variant="rect" width={"100%"} height={30} />
</Grid>
<Grid item xs={12}>
<Skeleton variant="rect" width={"100%"} height={30} />
</Grid>
</Grid>
</AppPaper>
);
};
export const ProvidersInstances = () => {
const styles = useStyles();
const providers = trpcClient.providersConfiguration.getAll.useQuery();
const { setInstanceId } = useInstanceId();
if (providers?.isFetching) {
return <ProvidersSkeleton />;
}
if (providers.error) {
return <div>Error. No provider available</div>;
}
const isAnyProvider = providers.data?.length !== 0;
if (!isAnyProvider) {
return <div></div>;
}
return (
<Grid container spacing={2}>
<Grid item xs={12}>
<TaxProvidersInstancesList />
</Grid>
<Grid item xs={12}>
<Button
variant="secondary"
startIcon={<Add />}
className={styles.button}
fullWidth
onClick={() => setInstanceId(null)}
>
Add provider
</Button>
</Grid>
</Grid>
);
};

View file

@ -0,0 +1,43 @@
import { TaxBaseFragment } from "../../../generated/graphql";
import { createLogger } from "../../lib/logger";
import { ChannelConfig } from "../channels-configuration/channels-config";
import { ProviderConfig } from "../providers-configuration/providers-config";
import { AvataxProvider } from "./providers/avatax/avatax-provider";
import { TaxJarProvider } from "./providers/taxjar/taxjar-provider";
import { TaxProvider } from "./tax-provider";
import { TaxProviderError } from "./tax-provider-error";
export class ActiveTaxProvider {
private client: TaxProvider;
constructor(providerInstance: ProviderConfig) {
const logger = createLogger({});
const taxProviderName = providerInstance.provider;
logger.info({ taxProviderName }, "Constructing tax provider: ");
switch (taxProviderName) {
case "taxjar":
this.client = new TaxJarProvider(providerInstance.config);
break;
case "avatax":
this.client = new AvataxProvider(providerInstance.config);
break;
default: {
throw new TaxProviderError(`Tax provider ${taxProviderName} doesnt match`, {
cause: "TaxProviderNotFound",
});
}
}
}
async calculate(payload: TaxBaseFragment, channel: ChannelConfig) {
return this.client.calculate(payload, channel);
}
async validate() {
return this.client.validate?.();
}
}

View file

@ -0,0 +1,8 @@
import { ResponseTaxPayload } from "./types";
export const defaultTaxesResponse: ResponseTaxPayload = {
lines: [],
shipping_price_gross_amount: 0,
shipping_price_net_amount: 0,
shipping_tax_rate: "0",
};

View file

@ -0,0 +1,19 @@
import { ResponseTaxPayload } from "./types";
export const MOCKED_RESPONSE_TAX_PAYLOAD: ResponseTaxPayload = {
lines: [
{
tax_rate: "10",
total_gross_amount: 4,
total_net_amount: 3,
},
{
tax_rate: "10",
total_gross_amount: 20,
total_net_amount: 5,
},
],
shipping_price_gross_amount: 0,
shipping_price_net_amount: 0,
shipping_tax_rate: "10",
};

View file

@ -0,0 +1,111 @@
import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel";
import { LineItemModel } from "avatax/lib/models/LineItemModel";
import { TransactionModel } from "avatax/lib/models/TransactionModel";
import { TaxBaseFragment } from "../../../../../generated/graphql";
import { ResponseTaxPayload } from "../../types";
import { ChannelConfig } from "../../../channels-configuration/channels-config";
import { taxLineResolver } from "../../tax-line-resolver";
import { AvataxConfig } from "./avatax-config";
const SHIPPING_ITEM_CODE = "Shipping";
const formatCalculatedAmount = (amount: number) => {
return Number(amount.toFixed(2));
};
const prepareLines = (taxBase: TaxBaseFragment): LineItemModel[] => {
const productLines = taxBase.lines.map((line) => ({
amount: line.unitPrice.amount,
taxIncluded: line.chargeTaxes,
taxCode: taxLineResolver.getLineTaxCode(line),
quantity: line.quantity,
itemCode: "Product",
}));
if (taxBase.shippingPrice.amount !== 0) {
// * In Avatax, shipping is a regular line
const shippingLine: LineItemModel = {
amount: taxBase.shippingPrice.amount,
itemCode: SHIPPING_ITEM_CODE,
quantity: 1,
};
return [...productLines, shippingLine];
}
return productLines;
};
const defaultAvataxTransactionModel = {
// todo: what is customerCode
customerCode: "0",
type: 0,
};
const preparePayload = (
taxBase: TaxBaseFragment,
channel: ChannelConfig,
config: AvataxConfig
): CreateTransactionModel => {
return {
...defaultAvataxTransactionModel,
companyCode: config.companyName,
// * commit: If true, the transaction will be committed immediately after it is created. See: https://developer.avalara.com/communications/dev-guide_rest_v2/commit-uncommit
commit: config.isAutocommit,
addresses: {
shipFrom: {
line1: channel.address.street,
city: channel.address.city,
region: channel.address.state,
postalCode: channel.address.zip,
country: channel.address.country,
},
shipTo: {
line1: taxBase.address?.streetAddress1,
line2: taxBase.address?.streetAddress2,
city: taxBase.address?.city,
country: taxBase.address?.country.code,
postalCode: taxBase.address?.postalCode,
region: taxBase.address?.countryArea,
},
},
lines: prepareLines(taxBase),
// todo: replace date with order/checkout date
date: new Date(),
};
};
const prepareResponse = (transaction: TransactionModel): ResponseTaxPayload => {
const shippingLine = transaction.lines?.find((line) => line.itemCode === SHIPPING_ITEM_CODE);
const productLines = transaction.lines?.filter((line) => line.itemCode !== SHIPPING_ITEM_CODE);
const shippingGrossAmount = shippingLine?.taxableAmount ?? 0;
const shippingTaxCalculated = shippingLine?.taxCalculated ?? 0;
const shippingNetAmount = formatCalculatedAmount(shippingGrossAmount - shippingTaxCalculated);
return {
shipping_price_gross_amount: shippingGrossAmount,
shipping_price_net_amount: shippingNetAmount,
// todo: add shipping tax rate
shipping_tax_rate: "0",
lines:
productLines?.map((line) => {
const lineTaxCalculated = line.taxCalculated ?? 0;
const lineTotalNetAmount = line.taxableAmount ?? 0;
const lineTotalGrossAmount = formatCalculatedAmount(lineTotalNetAmount + lineTaxCalculated);
return {
total_gross_amount: lineTotalGrossAmount,
total_net_amount: lineTotalNetAmount,
// todo: add tax rate
tax_rate: "0",
};
}) ?? [],
};
};
export const avataxCalculate = {
preparePayload,
prepareResponse,
prepareLines,
};

View file

@ -0,0 +1,60 @@
import Avatax from "avatax";
import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel";
import packageJson from "../../../../../package.json";
import { logger } from "../../../../lib/logger";
import { AvataxConfig } from "./avatax-config";
type AvataxSettings = {
appName: string;
appVersion: string;
environment: "sandbox" | "production";
machineName: string;
timeout: number;
logOptions?: {
logEnabled: boolean;
logLevel: number;
logRequestAndResponseInfo: boolean;
};
};
const defaultAvataxSettings: AvataxSettings = {
appName: packageJson.name,
appVersion: packageJson.version,
environment: "sandbox",
machineName: "tax-app",
timeout: 5000,
};
const createAvataxSettings = (config: AvataxConfig): AvataxSettings => {
const settings: AvataxSettings = {
...defaultAvataxSettings,
environment: config.isSandbox ? "sandbox" : "production",
};
return settings;
};
export class AvataxClient {
private client: Avatax;
constructor(config: AvataxConfig) {
logger.debug("AvataxClient constructor");
const { username, password } = config;
const credentials = {
username,
password,
};
const settings = createAvataxSettings(config);
const avataxClient = new Avatax(settings).withSecurity(credentials);
logger.info({ client: avataxClient }, "External Avatax client created");
this.client = avataxClient;
}
async fetchTaxesForOrder(model: CreateTransactionModel) {
return this.client.createTransaction({ model });
}
async ping() {
return this.client.ping();
}
}

View file

@ -0,0 +1,39 @@
import { z } from "zod";
import { obfuscateSecret } from "../../../../lib/utils";
import { createProviderInstanceSchema } from "../../tax-common-schema";
const avataxConfigSchema = z.object({
username: z.string().min(1, { message: "Username requires at least one character." }),
password: z.string().min(1, { message: "Password requires at least one character." }),
isSandbox: z.boolean(),
companyName: z.string().min(1, { message: "Company name requires at least one character." }),
isAutocommit: z.boolean(),
});
export type AvataxConfig = z.infer<typeof avataxConfigSchema>;
export const defaultAvataxConfig: AvataxConfig = {
username: "",
password: "",
companyName: "",
isSandbox: true,
isAutocommit: false,
};
export const avataxInstanceConfigSchema = createProviderInstanceSchema(
"avatax",
avataxConfigSchema
);
const transformedAvataxConfigSchema = avataxConfigSchema.transform((config) => {
return {
...config,
username: obfuscateSecret(config.username),
password: obfuscateSecret(config.username),
};
});
export const serverAvataxSchema = createProviderInstanceSchema(
"avatax",
transformedAvataxConfigSchema
);

View file

@ -0,0 +1,48 @@
import { TaxBaseFragment } from "../../../../../generated/graphql";
import { logger } from "../../../../lib/logger";
import { ChannelConfig } from "../../../channels-configuration/channels-config";
import { TaxProvider } from "../../tax-provider";
import { avataxCalculate } from "./avatax-calculate";
import { AvataxClient } from "./avatax-client";
import { AvataxConfig, defaultAvataxConfig } from "./avatax-config";
export class AvataxProvider implements TaxProvider {
readonly name = "avatax";
config = defaultAvataxConfig;
client: AvataxClient;
constructor(config: AvataxConfig) {
const avataxClient = new AvataxClient(config);
logger.info({ client: avataxClient }, "Internal Avatax client created");
this.config = config;
this.client = avataxClient;
}
async validate() {
logger.info("Avatax validate");
const validation = await this.client.ping();
logger.info(validation, "Avatax ping result");
if (validation.authenticated) {
return {
ok: true,
};
}
return {
ok: false,
error:
"Avalara was unable to authenticate. Check if the username and password you provided are correct.",
};
}
async calculate(payload: TaxBaseFragment, channel: ChannelConfig) {
logger.info("Avatax calculate");
const model = avataxCalculate.preparePayload(payload, channel, this.config);
logger.info(model, "Payload used for Avatax fetchTaxesForOrder");
const result = await this.client.fetchTaxesForOrder(model);
logger.info({ createOrderTransaction: result }, "Avatax createOrderTransaction response");
return avataxCalculate.prepareResponse(result);
}
}

View file

@ -0,0 +1,313 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
FormHelperText,
Grid,
InputLabel,
Switch,
TextField,
TextFieldProps,
} from "@material-ui/core";
import { Delete, Save } from "@material-ui/icons";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Button, makeStyles } from "@saleor/macaw-ui";
import React from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { trpcClient } from "../../../../trpc/trpc-client";
import { AppLink } from "../../../../ui/app-link";
import { useInstanceId } from "../../../tax-context";
import { avataxInstanceConfigSchema } from "../avatax-config";
const useStyles = makeStyles((theme) => ({
reverseRow: {
display: "flex",
flexDirection: "row-reverse",
gap: theme.spacing(1),
},
}));
const schema = avataxInstanceConfigSchema.omit({ provider: true });
type FormValues = z.infer<typeof schema>;
const defaultValues: FormValues = {
config: {
companyName: "",
isAutocommit: false,
isSandbox: false,
password: "",
username: "",
},
name: "",
};
export const AvataxConfigurationForm = () => {
const [isWarningDialogOpen, setIsWarningDialogOpen] = React.useState(false);
const styles = useStyles();
const { appBridge } = useAppBridge();
const { handleSubmit, reset, control, formState } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues,
});
const { instanceId, setInstanceId } = useInstanceId();
const { refetch: refetchChannelConfigurationData } =
trpcClient.channelsConfiguration.fetch.useQuery();
const { data: providersConfigurationData, refetch: refetchProvidersConfigurationData } =
trpcClient.providersConfiguration.getAll.useQuery();
const instance = providersConfigurationData?.find((instance) => instance.id === instanceId);
const resetInstanceId = () => {
setInstanceId(null);
};
React.useEffect(() => {
if (instance) {
const { provider, id, ...values } = instance;
reset(values);
} else {
reset(defaultValues);
}
}, [instance, reset]);
const { mutate: createMutation, isLoading: isCreateLoading } =
trpcClient.providersConfiguration.create.useMutation({
onSuccess({ id }) {
setInstanceId(id);
refetchProvidersConfigurationData();
appBridge?.dispatch(
actions.Notification({
title: "Success",
text: "Saved app configuration",
status: "success",
})
);
},
onError(error) {
appBridge?.dispatch(
actions.Notification({
title: "Error",
text: error.message,
status: "error",
})
);
},
});
const { mutate: updateMutation, isLoading: isUpdateLoading } =
trpcClient.providersConfiguration.update.useMutation({
onSuccess() {
refetchProvidersConfigurationData();
appBridge?.dispatch(
actions.Notification({
title: "Success",
text: "Updated Avalara configuration",
status: "success",
})
);
},
onError(error) {
appBridge?.dispatch(
actions.Notification({
title: "Error",
text: error.message,
status: "error",
})
);
},
});
const { mutate: deleteMutation } = trpcClient.providersConfiguration.delete.useMutation({
onSuccess() {
resetInstanceId();
refetchProvidersConfigurationData();
refetchChannelConfigurationData();
appBridge?.dispatch(
actions.Notification({
title: "Success",
text: "Remove TaxJar instance",
status: "success",
})
);
},
onError(error) {
appBridge?.dispatch(
actions.Notification({
title: "Error",
text: error.message,
status: "error",
})
);
},
});
const textFieldProps: TextFieldProps = {
fullWidth: true,
};
const onSubmit = (values: FormValues) => {
if (instanceId) {
updateMutation({
id: instanceId,
provider: {
...values,
provider: "avatax",
},
});
} else {
createMutation({
provider: {
...values,
provider: "avatax",
},
});
}
};
const closeWarningDialog = () => {
setIsWarningDialogOpen(false);
};
const openWarningDialog = () => {
setIsWarningDialogOpen(true);
};
const deleteProvider = () => {
closeWarningDialog();
if (instanceId) {
deleteMutation({ id: instanceId });
}
};
const isLoading = isCreateLoading || isUpdateLoading;
return (
<>
<form autoComplete="off" onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Controller
name="name"
control={control}
defaultValue={defaultValues.name}
render={({ field }) => (
<TextField type="text" {...field} label="Instance name" {...textFieldProps} />
)}
/>
{formState.errors.name && (
<FormHelperText error>{formState.errors.name.message}</FormHelperText>
)}
</Grid>
<Grid item xs={12}>
<InputLabel>
Sandbox
<Controller
name={"config.isSandbox"}
control={control}
defaultValue={defaultValues.config.isSandbox}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
</InputLabel>
<FormHelperText>
Toggling between{" "}
<AppLink
href={
"https://developer.avalara.com/erp-integration-guide/sales-tax-badge/authentication-in-avatax/sandbox-vs-production/"
}
>
<q>Production</q> and <q>Sandbox</q>
</AppLink>{" "}
environments.{" "}
</FormHelperText>
</Grid>
<Grid item xs={12}>
<InputLabel>
Autocommit
<Controller
name={"config.isAutocommit"}
control={control}
defaultValue={defaultValues.config.isAutocommit}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
</InputLabel>
<FormHelperText>
If enabled, the order will be automatically{" "}
<AppLink
href={
"https://developer.avalara.com/communications/dev-guide_rest_v2/commit-uncommit/"
}
>
committed to Avalara.
</AppLink>{" "}
</FormHelperText>
</Grid>
<Grid item xs={12}>
<Controller
name="config.username"
control={control}
defaultValue=""
render={({ field }) => (
<TextField type="text" {...field} label="Username" {...textFieldProps} />
)}
/>
{formState.errors.config?.username && (
<FormHelperText error>{formState.errors.config.username.message}</FormHelperText>
)}
</Grid>
<Grid item xs={12}>
<Controller
name="config.password"
control={control}
defaultValue={defaultValues.config.password}
render={({ field }) => <TextField label="Password" {...field} {...textFieldProps} />}
/>
{formState.errors.config?.password && (
<FormHelperText error>{formState.errors.config.password.message}</FormHelperText>
)}
</Grid>
<Grid item xs={12}>
<Controller
name="config.companyName"
control={control}
defaultValue={defaultValues.config.companyName}
render={({ field }) => (
<TextField type="text" {...field} label="Company name" {...textFieldProps} />
)}
/>
{formState.errors.config?.companyName && (
<FormHelperText error>{formState.errors.config.companyName.message}</FormHelperText>
)}
</Grid>
</Grid>
<br />
<div className={styles.reverseRow}>
<Button startIcon={<Save />} type="submit" variant="primary">
{isLoading ? "Saving..." : "Save"}
</Button>
{instanceId && (
<Button onClick={deleteProvider} startIcon={<Delete />}>
Delete
</Button>
)}
</div>
</form>
{/* <DeleteProviderDialog
isOpen={isWarningDialogOpen}
onClose={closeWarningDialog}
onCancel={closeWarningDialog}
onConfirm={deleteProvider}
/> */}
</>
);
};

View file

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

View file

@ -0,0 +1,14 @@
import { AvataxIcon, TaxJarIcon } from "../../../assets";
export const providerConfig = {
taxjar: {
label: "TaxJar",
icon: TaxJarIcon,
},
avatax: {
label: "Avalara",
icon: AvataxIcon,
},
};
export type TaxProviderName = keyof typeof providerConfig;

View file

@ -0,0 +1,121 @@
import { TaxParams } from "taxjar/dist/types/paramTypes";
import { TaxForOrderRes } from "taxjar/dist/types/returnTypes";
import {
TaxBaseFragment,
TaxBaseLineFragment,
TaxDiscountFragment,
} from "../../../../../generated/graphql";
import { FetchTaxesLinePayload, ResponseTaxPayload } from "../../types";
import { ChannelConfig } from "../../../channels-configuration/channels-config";
import { taxLineResolver } from "../../tax-line-resolver";
const formatCalculatedAmount = (amount: number) => {
return Number(amount.toFixed(2));
};
const prepareLinesWithDiscountPayload = (
lines: Array<TaxBaseLineFragment>,
discounts: Array<TaxDiscountFragment>
): Array<FetchTaxesLinePayload> => {
const allLinesTotal = lines.reduce(
(total, current) => total + Number(current.totalPrice.amount),
0
);
const discountsSum =
discounts?.reduce((total, current) => total + Number(current.amount.amount), 0) || 0;
// Make sure that totalDiscount doesn't exceed a sum of all lines
const totalDiscount = discountsSum <= allLinesTotal ? discountsSum : allLinesTotal;
return lines.map((line) => {
const discountAmount = taxLineResolver.getLineDiscount(line, totalDiscount, allLinesTotal);
const taxCode = taxLineResolver.getLineTaxCode(line);
return {
id: line.sourceLine.id,
chargeTaxes: line.chargeTaxes,
taxCode: taxCode,
quantity: line.quantity,
totalAmount: Number(line.totalPrice.amount),
unitAmount: Number(line.unitPrice.amount),
discount: discountAmount,
};
});
};
const prepareResponse = (
payload: TaxBaseFragment,
response: TaxForOrderRes,
linesWithChargeTaxes: FetchTaxesLinePayload[],
linesWithDiscount: FetchTaxesLinePayload[]
): ResponseTaxPayload => {
const taxResponse = linesWithChargeTaxes.length !== 0 ? response : undefined;
const taxDetails = taxResponse?.tax.breakdown;
// todo: investigate
// ! There is no shipping in tax.breakdown from TaxJar.
const shippingDetails = taxDetails?.shipping;
const shippingPriceGross = shippingDetails
? shippingDetails.taxable_amount + shippingDetails.tax_collectable
: payload.shippingPrice.amount;
const shippingPriceNet = shippingDetails
? shippingDetails.taxable_amount
: payload.shippingPrice.amount;
const shippingTaxRate = shippingDetails ? shippingDetails.combined_tax_rate : 0;
// ! It appears shippingTaxRate is always 0 from TaxJar.
return {
shipping_price_gross_amount: formatCalculatedAmount(shippingPriceGross),
shipping_price_net_amount: formatCalculatedAmount(shippingPriceNet),
shipping_tax_rate: String(shippingTaxRate),
// lines order needs to be the same as for recieved payload.
// lines that have chargeTaxes === false will have returned default value
lines: linesWithDiscount.map((line) => {
const lineTax = taxDetails?.line_items?.find((l) => l.id === line.id);
const totalGrossAmount = lineTax
? lineTax.taxable_amount + lineTax.tax_collectable
: line.totalAmount - line.discount;
const totalNetAmount = lineTax ? lineTax.taxable_amount : line.totalAmount - line.discount;
const taxRate = lineTax ? String(lineTax.combined_tax_rate || 0) : "0";
return {
total_gross_amount: formatCalculatedAmount(totalGrossAmount),
total_net_amount: formatCalculatedAmount(totalNetAmount),
tax_rate: taxRate,
};
}),
};
};
const preparePayload = (
taxBase: TaxBaseFragment,
channel: ChannelConfig,
linesWithChargeTaxes: FetchTaxesLinePayload[]
): TaxParams => {
const taxParams = {
from_country: channel.address.country,
from_zip: channel.address.zip,
from_state: channel.address.state,
from_city: channel.address.city,
from_street: channel.address.street,
to_country: taxBase.address!.country.code,
to_zip: taxBase.address!.postalCode,
to_state: taxBase.address!.countryArea,
to_city: taxBase.address!.city,
to_street: `${taxBase.address!.streetAddress1} ${taxBase.address!.streetAddress2}`,
shipping: taxBase.shippingPrice.amount,
line_items: linesWithChargeTaxes.map((line) => ({
id: line.id,
quantity: line.quantity,
product_tax_code: line.taxCode || undefined,
unit_price: line.unitAmount,
discount: line.discount,
})),
};
return taxParams;
};
export const taxJarCalculate = {
prepareLinesWithDiscountPayload,
prepareResponse,
preparePayload,
};

View file

@ -0,0 +1,30 @@
import TaxJar from "taxjar";
import { Config, TaxForOrderRes, TaxParams } from "taxjar/dist/util/types";
import { logger } from "../../../../lib/logger";
import { TaxJarConfig } from "./taxjar-config";
const createTaxJarSettings = (config: TaxJarConfig): Config => {
const settings: Config = {
apiKey: config.apiKey,
apiUrl: config.isSandbox ? TaxJar.SANDBOX_API_URL : TaxJar.DEFAULT_API_URL,
};
return settings;
};
export class TaxJarClient {
private client: TaxJar;
constructor(providerConfig: TaxJarConfig) {
logger.debug("TaxJarClient constructor");
const settings = createTaxJarSettings(providerConfig);
const taxJarClient = new TaxJar(settings);
logger.info({ client: taxJarClient }, "External TaxJar client created");
this.client = taxJarClient;
}
async fetchTaxesForOrder(params: TaxParams) {
const response: TaxForOrderRes = await this.client.taxForOrder(params);
return response;
}
}

View file

@ -0,0 +1,31 @@
import { z } from "zod";
import { obfuscateSecret } from "../../../../lib/utils";
import { createProviderInstanceSchema } from "../../tax-common-schema";
export const taxJarConfigSchema = z.object({
apiKey: z.string().min(1, { message: "API Key requires at least one character." }),
isSandbox: z.boolean(),
});
export type TaxJarConfig = z.infer<typeof taxJarConfigSchema>;
export const defaultTaxJarConfig: TaxJarConfig = {
apiKey: "",
isSandbox: false,
};
export const taxJarInstanceConfigSchema = createProviderInstanceSchema(
"taxjar",
taxJarConfigSchema
);
const transformedTaxJarConfigSchema = taxJarConfigSchema.transform((config) => {
return {
...config,
apiKey: obfuscateSecret(config.apiKey),
};
});
export const serverTaxJarSchema = createProviderInstanceSchema(
"taxjar",
transformedTaxJarConfigSchema
);

View file

@ -0,0 +1,44 @@
import { TaxBaseFragment } from "../../../../../generated/graphql";
import { logger } from "../../../../lib/logger";
import {
ChannelConfig,
defaultChannelConfig,
} from "../../../channels-configuration/channels-config";
import { TaxProvider } from "../../tax-provider";
import { taxJarCalculate } from "./taxjar-calculate";
import { TaxJarClient } from "./taxjar-client";
import { defaultTaxJarConfig, TaxJarConfig } from "./taxjar-config";
export class TaxJarProvider implements TaxProvider {
readonly name = "taxjar";
config = defaultTaxJarConfig;
channel = defaultChannelConfig;
client: TaxJarClient;
constructor(config: TaxJarConfig) {
const avataxClient = new TaxJarClient(config);
this.config = config;
this.client = avataxClient;
}
async calculate(payload: TaxBaseFragment, channel: ChannelConfig) {
logger.info("TaxJar calculate");
const linesWithDiscount = taxJarCalculate.prepareLinesWithDiscountPayload(
payload.lines,
payload.discounts
);
const linesWithChargeTaxes = linesWithDiscount.filter((line) => line.chargeTaxes === true);
const taxParams = taxJarCalculate.preparePayload(payload, channel, linesWithDiscount);
logger.info(taxParams, "Payload used for TaxJar fetchTaxesForOrder");
const fetchedTaxes = await this.client.fetchTaxesForOrder(taxParams);
logger.info({ fetchedTaxes }, "TaxJar createOrderTransaction response");
return taxJarCalculate.prepareResponse(
payload,
fetchedTaxes,
linesWithChargeTaxes,
linesWithDiscount
);
}
}

View file

@ -0,0 +1,251 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
FormHelperText,
Grid,
InputLabel,
Switch,
TextField,
TextFieldProps,
} from "@material-ui/core";
import { Delete, Save } from "@material-ui/icons";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Button, makeStyles } from "@saleor/macaw-ui";
import React from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { trpcClient } from "../../../../trpc/trpc-client";
import { useInstanceId } from "../../../tax-context";
import { taxJarInstanceConfigSchema } from "../taxjar-config";
const useStyles = makeStyles((theme) => ({
reverseRow: {
display: "flex",
flexDirection: "row-reverse",
gap: theme.spacing(1),
},
}));
const schema = taxJarInstanceConfigSchema.omit({ provider: true });
type FormValues = z.infer<typeof schema>;
const defaultValues: FormValues = {
config: {
apiKey: "",
isSandbox: false,
},
name: "",
};
export const TaxJarConfigurationForm = () => {
const [isWarningDialogOpen, setIsWarningDialogOpen] = React.useState(false);
const styles = useStyles();
const { instanceId, setInstanceId } = useInstanceId();
const { appBridge } = useAppBridge();
const { handleSubmit, reset, control, formState } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues,
});
const resetInstanceId = () => {
setInstanceId(null);
};
const { refetch: refetchChannelConfigurationData } =
trpcClient.channelsConfiguration.fetch.useQuery();
const { data: providersConfigurationData, refetch: refetchProvidersConfigurationData } =
trpcClient.providersConfiguration.getAll.useQuery();
const instance = providersConfigurationData?.find((instance) => instance.id === instanceId);
const { mutate: createMutation, isLoading: isCreateLoading } =
trpcClient.providersConfiguration.create.useMutation({
onSuccess({ id }) {
setInstanceId(id);
refetchProvidersConfigurationData();
refetchChannelConfigurationData();
appBridge?.dispatch(
actions.Notification({
title: "Success",
text: "Saved TaxJar configuration",
status: "success",
})
);
},
onError(error) {
appBridge?.dispatch(
actions.Notification({
title: "Error",
text: error.message,
status: "error",
})
);
},
});
const { mutate: updateMutation, isLoading: isUpdateLoading } =
trpcClient.providersConfiguration.update.useMutation({
onSuccess() {
refetchProvidersConfigurationData();
refetchChannelConfigurationData();
appBridge?.dispatch(
actions.Notification({
title: "Success",
text: "Updated TaxJar configuration",
status: "success",
})
);
},
onError(error) {
appBridge?.dispatch(
actions.Notification({
title: "Error",
text: error.message,
status: "error",
})
);
},
});
const { mutate: deleteMutation, isLoading: isDeleteLoading } =
trpcClient.providersConfiguration.delete.useMutation({
onSuccess() {
resetInstanceId();
refetchProvidersConfigurationData();
refetchChannelConfigurationData();
appBridge?.dispatch(
actions.Notification({
title: "Success",
text: "Remove TaxJar instance",
status: "success",
})
);
},
onError(error) {
appBridge?.dispatch(
actions.Notification({
title: "Error",
text: error.message,
status: "error",
})
);
},
});
React.useEffect(() => {
if (instance) {
const { provider, id, ...values } = instance;
reset(values);
} else {
reset({ ...defaultValues });
}
}, [instance, instanceId, reset]);
const textFieldProps: TextFieldProps = {
fullWidth: true,
};
const onSubmit = (values: FormValues) => {
if (instanceId) {
updateMutation({
id: instanceId,
provider: {
...values,
provider: "taxjar",
},
});
} else {
createMutation({
provider: {
...values,
provider: "taxjar",
},
});
}
};
const closeWarningDialog = () => {
setIsWarningDialogOpen(false);
};
const openWarningDialog = () => {
setIsWarningDialogOpen(true);
};
const deleteProvider = () => {
closeWarningDialog();
if (instanceId) {
deleteMutation({ id: instanceId });
}
};
const isLoading = isCreateLoading || isUpdateLoading;
return (
<>
<form onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Controller
name="name"
control={control}
defaultValue={defaultValues.name}
render={({ field }) => (
<TextField type="text" {...field} label="Instance name" {...textFieldProps} />
)}
/>
{formState.errors.name && (
<FormHelperText error>{formState.errors.name.message}</FormHelperText>
)}
</Grid>
<Grid item xs={12}>
<Controller
name="config.apiKey"
control={control}
defaultValue={defaultValues.config.apiKey}
render={({ field }) => <TextField label="API Key" {...field} {...textFieldProps} />}
/>
{formState.errors.config?.apiKey && (
<FormHelperText error>{formState.errors.config?.apiKey.message}</FormHelperText>
)}
</Grid>
<Grid item xs={12}>
<InputLabel>
Sandbox
<Controller
name={"config.isSandbox"}
control={control}
defaultValue={defaultValues.config.isSandbox}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
</InputLabel>
</Grid>
</Grid>
<br />
<div className={styles.reverseRow}>
<Button startIcon={<Save />} type="submit" variant="primary">
{isLoading ? "Saving..." : "Save"}
</Button>
{instanceId && (
<Button onClick={deleteProvider} startIcon={<Delete />}>
{isDeleteLoading ? "Deleting..." : "Delete"}
</Button>
)}
</div>
</form>
{/* // todo: bring back to life once Dashboard allows to summon dialog */}
{/* <DeleteProviderDialog
isOpen={isWarningDialogOpen}
onClose={closeWarningDialog}
onCancel={closeWarningDialog}
onConfirm={deleteProvider}
/> */}
</>
);
};

View file

@ -0,0 +1,10 @@
import { TaxJarConfigurationForm } from "./taxjar-configuration-form";
export const TaxJarConfiguration = () => {
return (
<section>
<h2>TaxJar configuration</h2>
<TaxJarConfigurationForm />
</section>
);
};

View file

@ -0,0 +1,26 @@
import { z, ZodTypeAny } from "zod";
import { TaxProviderName } from "./providers/config";
export const addressSchema = z.object({
country: z.string(),
zip: z.string(),
state: z.string(),
city: z.string(),
street: z.string(),
});
const baseProviderInstanceSchema = z.object({
name: z.string().min(1),
});
export const createProviderInstanceSchema = <
TConfig extends ZodTypeAny,
TProvider extends TaxProviderName
>(
provider: TProvider,
schema: TConfig
) =>
baseProviderInstanceSchema.extend({
provider: z.literal(provider),
config: schema,
});

View file

@ -0,0 +1,26 @@
import { atom, useAtom } from "jotai";
import { AppTab } from "../../pages/configuration";
const channelSlugAtom = atom("");
export const useChannelSlug = () => {
const [channelSlug, setChannelSlug] = useAtom(channelSlugAtom);
return { channelSlug, setChannelSlug };
};
const instanceIdAtom = atom<string | null>(null);
export const useInstanceId = () => {
const [instanceId, setInstanceId] = useAtom(instanceIdAtom);
return { instanceId, setInstanceId };
};
const activeTabAtom = atom<AppTab>("channels");
export const useActiveTab = () => {
const [activeTab, setActiveTab] = useAtom(activeTabAtom);
return { activeTab, setActiveTab };
};

View file

@ -0,0 +1,38 @@
import { TaxBaseLineFragment } from "../../../generated/graphql";
const getLineDiscount = (
line: TaxBaseLineFragment,
totalDiscount: number,
allLinesTotal: number
) => {
if (totalDiscount === 0 || allLinesTotal === 0) {
return 0;
}
const lineTotalAmount = Number(line.totalPrice.amount);
const discountAmount = (lineTotalAmount / allLinesTotal) * totalDiscount;
if (discountAmount > lineTotalAmount) {
return lineTotalAmount;
}
return discountAmount;
};
const getLineTaxCode = (line: TaxBaseLineFragment): string => {
if (line.sourceLine.__typename === "OrderLine") {
return (
line.sourceLine.variant?.product.metafield ??
line.sourceLine.variant?.product.productType.metafield ??
""
);
}
return (
(line.sourceLine.productVariant.product.metafield ||
line.sourceLine.productVariant.product.productType.metafield) ??
""
);
};
export const taxLineResolver = {
getLineDiscount,
getLineTaxCode,
};

View file

@ -0,0 +1,8 @@
export type TaxProviderValidationError = "TaxProviderNotFound";
type TaxProviderErrorName = TaxProviderValidationError;
export class TaxProviderError extends Error {
constructor(message: string, options: { cause: TaxProviderErrorName }) {
super(message, options);
}
}

View file

@ -0,0 +1,12 @@
import { TaxBaseFragment } from "../../../generated/graphql";
import { ResponseTaxPayload } from "./types";
import { ChannelConfig } from "../channels-configuration/channels-config";
import { TaxProviderName } from "./providers/config";
type ExternalValidationResult = { ok: boolean; error?: string };
export interface TaxProvider {
name: TaxProviderName;
calculate: (payload: TaxBaseFragment, channel: ChannelConfig) => Promise<ResponseTaxPayload>;
validate?: () => Promise<ExternalValidationResult>;
}

View file

@ -0,0 +1,6 @@
import { SaleorSyncWebhook } from "../../lib/saleor/saleor-app-sdk";
import { ResponseTaxPayload } from "./types";
export class TaxSaleorSyncWebhook<TPayload = any> extends SaleorSyncWebhook<
TPayload,
ResponseTaxPayload
> {}

View file

@ -0,0 +1,22 @@
type LineTaxResponsePayload = {
total_gross_amount: number;
total_net_amount: number;
tax_rate: string;
};
export type ResponseTaxPayload = {
shipping_price_gross_amount: number;
shipping_price_net_amount: number;
shipping_tax_rate: string;
lines: LineTaxResponsePayload[];
};
export type FetchTaxesLinePayload = {
id: string;
quantity: number;
taxCode?: string | null;
discount: number;
chargeTaxes: boolean;
unitAmount: number;
totalAmount: number;
};

View file

@ -0,0 +1,117 @@
import { createClient } from "../../lib/graphql";
import { verifyJWT } from "@saleor/app-sdk/verify-jwt";
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";
const attachAppToken = middleware(async ({ ctx, next }) => {
logger.debug("attachAppToken middleware");
if (!ctx.saleorApiUrl) {
logger.debug("ctx.saleorApiUrl not found, throwing");
throw new TRPCError({
code: "BAD_REQUEST",
message: "Missing saleorApiUrl in request",
});
}
const authData = await saleorApp.apl.get(ctx.saleorApiUrl);
if (!authData) {
logger.debug("authData not found, throwing 401");
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Missing auth data",
});
}
return next({
ctx: {
appToken: authData.token,
saleorApiUrl: authData.saleorApiUrl,
appId: authData.appId,
},
});
});
const validateClientToken = middleware(async ({ ctx, next, meta }) => {
logger.debug(
{
permissions: meta?.requiredClientPermissions,
},
"Calling validateClientToken middleware with permissions required"
);
if (!ctx.token) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Missing token in request. This middleware can be used only in frontend",
});
}
if (!ctx.appId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Missing appId in request. This middleware can be used after auth is attached",
});
}
if (!ctx.saleorApiUrl) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"Missing saleorApiUrl in request. This middleware can be used after auth is attached",
});
}
try {
logger.debug("trying to verify JWT token from frontend");
logger.debug({ token: ctx.token ? `${ctx.token[0]}...` : undefined });
await verifyJWT({
appId: ctx.appId,
token: ctx.token,
saleorApiUrl: ctx.saleorApiUrl,
requiredPermissions: meta?.requiredClientPermissions ?? [],
});
} catch (e) {
logger.debug("JWT verification failed, throwing");
throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED");
}
return next({
ctx: {
...ctx,
saleorApiUrl: ctx.saleorApiUrl,
},
});
});
/**
* Construct common graphQL client and attach it to the context
*
* Can be used only if called from the frontend (react-query),
* otherwise jwks validation will fail (if createCaller used)
*
* TODO Rethink middleware composition to enable safe server-side router calls
*/
export const protectedClientProcedure = procedure
.use(attachAppToken)
.use(validateClientToken)
.use(async ({ ctx, next }) => {
const client = createClient(ctx.saleorApiUrl, async () =>
Promise.resolve({ token: ctx.appToken })
);
return next({
ctx: {
apiClient: client,
appToken: ctx.appToken,
saleorApiUrl: ctx.saleorApiUrl,
},
});
});

View file

@ -0,0 +1,12 @@
import { channelsRouter } from "../channels/channels.router";
import { router } from "./trpc-server";
import { providersConfigurationRouter } from "../providers-configuration/providers-configuration.router";
import { channelsConfigurationRouter } from "../channels-configuration/channels-configuration.router";
export const appRouter = router({
channels: channelsRouter,
providersConfiguration: providersConfigurationRouter,
channelsConfiguration: channelsConfigurationRouter,
});
export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,44 @@
import { httpBatchLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { appBridgeInstance } from "../../pages/_app";
import { AppRouter } from "./trpc-app-router";
function getBaseUrl() {
if (typeof window !== "undefined") return "";
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export const trpcClient = createTRPCNext<AppRouter>({
config({ ctx }) {
return {
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers() {
return {
/**
* Attach headers from app to client requests, so tRPC can add them to context
*/
[SALEOR_AUTHORIZATION_BEARER_HEADER]: appBridgeInstance?.getState().token,
[SALEOR_API_URL_HEADER]: appBridgeInstance?.getState().saleorApiUrl,
};
},
}),
],
queryClientConfig: {
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
},
},
};
},
ssr: false,
});

View file

@ -0,0 +1,13 @@
import * as trpcNext from "@trpc/server/adapters/next";
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const";
import { inferAsyncReturnType } from "@trpc/server";
export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => {
return {
token: req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER] as string | undefined,
saleorApiUrl: req.headers[SALEOR_API_URL_HEADER] as string | undefined,
appId: undefined as undefined | string,
};
};
export type TrpcContext = inferAsyncReturnType<typeof createTrpcContext>;

View file

@ -0,0 +1,13 @@
import { initTRPC } from "@trpc/server";
import { TrpcContext } from "./trpc-context";
import { AppPermission } from "@saleor/app-sdk/types";
interface Meta {
requiredClientPermissions?: AppPermission[];
}
const t = initTRPC.context<TrpcContext>().meta<Meta>().create();
export const router = t.router;
export const procedure = t.procedure;
export const middleware = t.middleware;

View file

@ -0,0 +1,14 @@
import { makeStyles } from "@saleor/macaw-ui";
export const useStyles = makeStyles({
root: {
maxWidth: 1180,
margin: "0 auto",
},
});
export const AppContainer = ({ children }: { children: React.ReactNode }) => {
const styles = useStyles();
return <div className={styles.root}>{children}</div>;
};

View file

@ -0,0 +1,19 @@
import { makeStyles } from "@saleor/macaw-ui";
import { PropsWithChildren } from "react";
export const useStyles = makeStyles({
root: {
display: "grid",
gridTemplateColumns: "280px auto 280px",
alignItems: "start",
gap: 32,
},
});
export type Props = PropsWithChildren<{}>;
export const AppGrid = ({ children }: Props) => {
const styles = useStyles();
return <div className={styles.root}>{children}</div>;
};

View file

@ -0,0 +1,11 @@
import React from "react";
import { AppContainer } from "./app-container";
import { AppGrid } from "./app-grid";
export const AppLayout = ({ children }: { children: React.ReactNode }) => {
return (
<AppContainer>
<AppGrid>{children}</AppGrid>
</AppContainer>
);
};

View file

@ -0,0 +1,13 @@
import { Link } from "@material-ui/core";
import { PropsWithChildren } from "react";
import { useAppRedirect } from "../../lib/app/redirect";
export const AppLink = ({ children, href }: PropsWithChildren<{ href: string }>) => {
const { redirect } = useAppRedirect();
return (
<Link target={"_blank"} href={href} rel="noreferrer" onClick={() => redirect(href)}>
{children}
</Link>
);
};

View file

@ -0,0 +1,18 @@
import { Paper } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
import React from "react";
const useStyles = makeStyles({
root: {
padding: "16px",
},
});
export const AppPaper = ({ children }: { children: React.ReactNode }) => {
const styles = useStyles();
return (
<Paper elevation={0} className={styles.root}>
{children}
</Paper>
);
};

View file

@ -0,0 +1,23 @@
import { Link, makeStyles } from "@material-ui/core";
import { PropsWithChildren } from "react";
import { AppTab } from "../../pages/configuration";
import { useActiveTab } from "../taxes/tax-context";
const useStyles = makeStyles((theme) => ({
button: {
fontSize: "inherit",
fontFamily: "inherit",
verticalAlign: "unset",
},
}));
export const AppTabNavButton = ({ children, to }: PropsWithChildren<{ to: AppTab }>) => {
const styles = useStyles();
const { setActiveTab } = useActiveTab();
return (
<Link className={styles.button} component="button" onClick={() => setActiveTab(to)}>
{children}
</Link>
);
};

View file

@ -0,0 +1,51 @@
import { Typography } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
import { AppLink } from "./app-link";
import { AppPaper } from "./app-paper";
import { AppTabNavButton } from "./app-tab-nav-button";
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
flexDirection: "column",
gap: "16px",
},
list: {
paddingLeft: "16px",
margin: 0,
color: "inherit",
},
}));
export const Instructions = () => {
const styles = useStyles();
return (
<AppPaper>
<div className={styles.root}>
<Typography variant="h4">Use external service for tax calculation</Typography>
<Typography variant="body1">
<ol className={styles.list}>
<li>
Go to <AppLink href="/taxes/channels">Tax Configuration</AppLink>. Make sure you chose{" "}
<q>Use tax app</q> as the method of tax calculation for your channel.
</li>
<li>
In the Tax App, go to the <AppTabNavButton to="providers">Providers</AppTabNavButton>{" "}
tab to add an instance of your provider. Click <q>Add provider</q>, and select the tax
provider you want to use. Fill in the configuration form and hit <q>Save</q>.
</li>
<li>
Go to the <AppTabNavButton to="channels">Channels</AppTabNavButton> tab. Select a
channel. In the <q>Channel tax provider</q> field, select the created instance. Fill
in the rest of the form, and hit <q>Save</q>.
</li>
<li>
Saleor will now use the channel&#39;s configured tax provider for order & checkout tax
calculations.
</li>
</ol>
</Typography>
</div>
</AppPaper>
);
};

View file

@ -0,0 +1,88 @@
import "../styles/globals.css";
import { Theme } from "@material-ui/core/styles";
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import {
dark,
light,
SaleorThemeColors,
ThemeProvider as MacawUIThemeProvider,
} from "@saleor/macaw-ui";
import React, { PropsWithChildren, useEffect } from "react";
import { AppProps } from "next/app";
import GraphQLProvider from "../providers/GraphQLProvider";
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
import { trpcClient } from "../modules/trpc/trpc-client";
import { NoSSRWrapper } from "../lib/no-ssr-wrapper";
const themeOverrides: Partial<Theme> = {
/**
* You can override MacawUI theme here
*/
};
/**
* Ensure instance is a singleton.
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
*/
export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
type PalettesOverride = Record<"light" | "dark", SaleorThemeColors>;
/**
* Temporary override of colors, to match new dashboard palette.
* Long term this will be replaced with Macaw UI 2.x with up to date design tokens
*/
const palettes: PalettesOverride = {
light: {
...light,
background: {
default: "#fff",
paper: "#fff",
},
},
dark: {
...dark,
background: {
default: "hsla(211, 42%, 14%, 1)",
paper: "hsla(211, 42%, 14%, 1)",
},
},
};
/**
* That's a hack required by Macaw-UI incompatibility with React@18
*/
const ThemeProvider = MacawUIThemeProvider as React.FC<
PropsWithChildren<{ overrides?: Partial<Theme>; ssr: boolean; palettes: PalettesOverride }>
>;
function NextApp({ Component, pageProps }: AppProps) {
/**
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
*/
useEffect(() => {
const jssStyles = document.querySelector("#jss-server-side");
if (jssStyles) {
jssStyles?.parentElement?.removeChild(jssStyles);
}
}, []);
return (
<NoSSRWrapper>
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
<GraphQLProvider>
<ThemeProvider palettes={palettes} overrides={themeOverrides} ssr>
<ThemeSynchronizer />
<RoutePropagator />
<Component {...pageProps} />
</ThemeProvider>
</GraphQLProvider>
</AppBridgeProvider>
</NoSSRWrapper>
);
}
export default trpcClient.withTRPC(NextApp);

View file

@ -0,0 +1,28 @@
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
import { AppManifest } from "@saleor/app-sdk/types";
import packageJson from "../../../package.json";
import { checkoutCalculateTaxesSyncWebhook } from "./webhooks/checkout-calculate-taxes";
import { orderCalculateTaxesSyncWebhook } from "./webhooks/order-calculate-taxes";
export default createManifestHandler({
async manifestFactory(context) {
const manifest: AppManifest = {
name: "Taxes",
tokenTargetUrl: `${context.appBaseUrl}/api/register`,
appUrl: context.appBaseUrl,
permissions: ["HANDLE_TAXES", "MANAGE_ORDERS"],
id: "saleor.app.taxes",
version: packageJson.version,
webhooks: [
orderCalculateTaxesSyncWebhook.getWebhookManifest(context.appBaseUrl),
checkoutCalculateTaxesSyncWebhook.getWebhookManifest(context.appBaseUrl),
],
extensions: [],
homepageUrl: "https://github.com/saleor/apps",
supportUrl: "https://github.com/saleor/apps/discussions",
};
return manifest;
},
});

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