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:
parent
62ddd41094
commit
9f0dd813a4
115 changed files with 39534 additions and 20 deletions
|
@ -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.
|
||||
- [search](./apps/search) - connect Saleor with search engines.
|
||||
- [slack](./apps/slack) - get notifications on Slack channel from Saleor events.
|
||||
- [taxes](./apps/taxes) - calculate order and checkout taxes using external services.
|
||||
|
||||
## Development
|
||||
|
||||
|
|
10
apps/taxes/.env.example
Normal file
10
apps/taxes/.env.example
Normal 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
4
apps/taxes/.eslintrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"root": true,
|
||||
"extends": ["saleor"]
|
||||
}
|
20
apps/taxes/.graphqlrc.yml
Normal file
20
apps/taxes/.graphqlrc.yml
Normal 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
1
apps/taxes/.npmrc
Normal file
|
@ -0,0 +1 @@
|
|||
strict-peer-dependencies=false
|
40
apps/taxes/README.md
Normal file
40
apps/taxes/README.md
Normal file
|
@ -0,0 +1,40 @@
|
|||

|
||||
|
||||
<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.
|
10
apps/taxes/graphql/fragments/Address.graphql
Normal file
10
apps/taxes/graphql/fragments/Address.graphql
Normal file
|
@ -0,0 +1,10 @@
|
|||
fragment AddressFragment on Address {
|
||||
streetAddress1
|
||||
streetAddress2
|
||||
city
|
||||
countryArea
|
||||
postalCode
|
||||
country {
|
||||
code
|
||||
}
|
||||
}
|
8
apps/taxes/graphql/fragments/CalculateTaxesEvent.graphql
Normal file
8
apps/taxes/graphql/fragments/CalculateTaxesEvent.graphql
Normal file
|
@ -0,0 +1,8 @@
|
|||
fragment CalculateTaxesEvent on Event {
|
||||
__typename
|
||||
... on CalculateTaxes {
|
||||
taxBase {
|
||||
...TaxBase
|
||||
}
|
||||
}
|
||||
}
|
5
apps/taxes/graphql/fragments/Channel.graphql
Normal file
5
apps/taxes/graphql/fragments/Channel.graphql
Normal file
|
@ -0,0 +1,5 @@
|
|||
fragment Channel on Channel {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
69
apps/taxes/graphql/fragments/TaxBase.graphql
Normal file
69
apps/taxes/graphql/fragments/TaxBase.graphql
Normal 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
|
||||
}
|
||||
}
|
10
apps/taxes/graphql/mutations/UpdateAppMetadata.graphql
Normal file
10
apps/taxes/graphql/mutations/UpdateAppMetadata.graphql
Normal file
|
@ -0,0 +1,10 @@
|
|||
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||
updatePrivateMetadata(id: $id, input: $input) {
|
||||
item {
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
9
apps/taxes/graphql/queries/FetchAppDetails.graphql
Normal file
9
apps/taxes/graphql/queries/FetchAppDetails.graphql
Normal file
|
@ -0,0 +1,9 @@
|
|||
query FetchAppDetails {
|
||||
app {
|
||||
id
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
6
apps/taxes/graphql/queries/FetchAppMetafields.graphql
Normal file
6
apps/taxes/graphql/queries/FetchAppMetafields.graphql
Normal file
|
@ -0,0 +1,6 @@
|
|||
query FetchAppMetafields($keys: [String!]) {
|
||||
app {
|
||||
id
|
||||
privateMetafields(keys: $keys)
|
||||
}
|
||||
}
|
11
apps/taxes/graphql/queries/FetchChannels.graphql
Normal file
11
apps/taxes/graphql/queries/FetchChannels.graphql
Normal file
|
@ -0,0 +1,11 @@
|
|||
query Channel($id: ID!) {
|
||||
channel(id: $id) {
|
||||
...Channel
|
||||
}
|
||||
}
|
||||
|
||||
query FetchChannels {
|
||||
channels {
|
||||
...Channel
|
||||
}
|
||||
}
|
51
apps/taxes/graphql/queries/TaxConfigurations.graphql
Normal file
51
apps/taxes/graphql/queries/TaxConfigurations.graphql
Normal 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
|
||||
}
|
26596
apps/taxes/graphql/schema.graphql
Normal file
26596
apps/taxes/graphql/schema.graphql
Normal file
File diff suppressed because it is too large
Load diff
5
apps/taxes/graphql/subscriptions/CalculateTaxes.graphql
Normal file
5
apps/taxes/graphql/subscriptions/CalculateTaxes.graphql
Normal file
|
@ -0,0 +1,5 @@
|
|||
subscription CalculateTaxes {
|
||||
event {
|
||||
...CalculateTaxesEvent
|
||||
}
|
||||
}
|
60
apps/taxes/graphql/subscriptions/OrderCreated.graphql
Normal file
60
apps/taxes/graphql/subscriptions/OrderCreated.graphql
Normal 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
5
apps/taxes/next-env.d.ts
vendored
Normal 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
10
apps/taxes/next.config.js
Normal 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
73
apps/taxes/package.json
Normal 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
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
36
apps/taxes/saleor-app.ts
Normal 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,
|
||||
});
|
9
apps/taxes/src/assets/app-icon.svg
Normal file
9
apps/taxes/src/assets/app-icon.svg
Normal 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 |
5
apps/taxes/src/assets/avatax-icon.svg
Normal file
5
apps/taxes/src/assets/avatax-icon.svg
Normal 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 |
2
apps/taxes/src/assets/index.ts
Normal file
2
apps/taxes/src/assets/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { default as AvataxIcon } from "./avatax-icon.svg";
|
||||
export { default as TaxJarIcon } from "./taxjar-icon.svg";
|
9
apps/taxes/src/assets/taxjar-icon.svg
Normal file
9
apps/taxes/src/assets/taxjar-icon.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 297 KiB |
18
apps/taxes/src/lib/app/redirect.ts
Normal file
18
apps/taxes/src/lib/app/redirect.ts
Normal 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 };
|
||||
};
|
45
apps/taxes/src/lib/graphql.ts
Normal file
45
apps/taxes/src/lib/graphql.ts
Normal 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,
|
||||
],
|
||||
});
|
16
apps/taxes/src/lib/logger.ts
Normal file
16
apps/taxes/src/lib/logger.ts
Normal 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);
|
8
apps/taxes/src/lib/no-ssr-wrapper.tsx
Normal file
8
apps/taxes/src/lib/no-ssr-wrapper.tsx
Normal 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,
|
||||
});
|
3
apps/taxes/src/lib/saleor/saleor-app-sdk/index.ts
Normal file
3
apps/taxes/src/lib/saleor/saleor-app-sdk/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export * from "./saleor-app-sdk-copy";
|
||||
export * from "./saleor-sync-webhook";
|
||||
export * from "./saleor-webhook";
|
|
@ -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
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
19
apps/taxes/src/lib/saleor/saleor-app-sdk/saleor-webhook.ts
Normal file
19
apps/taxes/src/lib/saleor/saleor-app-sdk/saleor-webhook.ts
Normal 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>;
|
||||
}
|
81
apps/taxes/src/lib/saleor/schema.ts
Normal file
81
apps/taxes/src/lib/saleor/schema.ts
Normal 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),
|
||||
}),
|
||||
});
|
104
apps/taxes/src/lib/saleor/with-taxes-webhook.ts
Normal file
104
apps/taxes/src/lib/saleor/with-taxes-webhook.ts
Normal 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);
|
||||
}
|
||||
};
|
48
apps/taxes/src/lib/theme-synchronizer.test.tsx
Normal file
48
apps/taxes/src/lib/theme-synchronizer.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
28
apps/taxes/src/lib/theme-synchronizer.tsx
Normal file
28
apps/taxes/src/lib/theme-synchronizer.tsx
Normal 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);
|
5
apps/taxes/src/lib/utils.ts
Normal file
5
apps/taxes/src/lib/utils.ts
Normal 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, "*");
|
40
apps/taxes/src/modules/app-configuration/app-configurator.ts
Normal file
40
apps/taxes/src/modules/app-configuration/app-configurator.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
68
apps/taxes/src/modules/app-configuration/metadata-manager.ts
Normal file
68
apps/taxes/src/modules/app-configuration/metadata-manager.ts
Normal 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),
|
||||
});
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from "zod";
|
||||
import { channelSchema } from "./channels-config";
|
||||
|
||||
export const setAndReplaceChannelsInputSchema = z.object({
|
||||
channelSlug: z.string(),
|
||||
config: channelSchema,
|
||||
});
|
|
@ -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
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}),
|
||||
});
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
}
|
||||
}
|
24
apps/taxes/src/modules/channels/channels-fetcher.ts
Normal file
24
apps/taxes/src/modules/channels/channels-fetcher.ts
Normal 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) ?? []
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
17
apps/taxes/src/modules/channels/channels.router.ts
Normal file
17
apps/taxes/src/modules/channels/channels.router.ts
Normal 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 ?? []);
|
||||
}),
|
||||
});
|
222
apps/taxes/src/modules/channels/ui/channel-tax-provider-form.tsx
Normal file
222
apps/taxes/src/modules/channels/ui/channel-tax-provider-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
158
apps/taxes/src/modules/channels/ui/channel-tax-provider.tsx
Normal file
158
apps/taxes/src/modules/channels/ui/channel-tax-provider.tsx
Normal 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>
|
||||
);
|
||||
};
|
70
apps/taxes/src/modules/channels/ui/channels-list.tsx
Normal file
70
apps/taxes/src/modules/channels/ui/channels-list.tsx
Normal 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>
|
||||
);
|
||||
};
|
56
apps/taxes/src/modules/channels/ui/channels.tsx
Normal file
56
apps/taxes/src/modules/channels/ui/channels.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
});
|
|
@ -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;
|
|
@ -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 };
|
||||
}),
|
||||
});
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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} />
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
43
apps/taxes/src/modules/taxes/active-tax-provider.ts
Normal file
43
apps/taxes/src/modules/taxes/active-tax-provider.ts
Normal 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?.();
|
||||
}
|
||||
}
|
8
apps/taxes/src/modules/taxes/defaults.ts
Normal file
8
apps/taxes/src/modules/taxes/defaults.ts
Normal 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",
|
||||
};
|
19
apps/taxes/src/modules/taxes/mocks.ts
Normal file
19
apps/taxes/src/modules/taxes/mocks.ts
Normal 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",
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/> */}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
import { AvataxConfigurationForm } from "./avatax-configuration-form";
|
||||
|
||||
export const AvataxConfiguration = () => {
|
||||
return (
|
||||
<section>
|
||||
<h2>Avalara configuration</h2>
|
||||
<AvataxConfigurationForm />
|
||||
</section>
|
||||
);
|
||||
};
|
14
apps/taxes/src/modules/taxes/providers/config.ts
Normal file
14
apps/taxes/src/modules/taxes/providers/config.ts
Normal 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;
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/> */}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
import { TaxJarConfigurationForm } from "./taxjar-configuration-form";
|
||||
|
||||
export const TaxJarConfiguration = () => {
|
||||
return (
|
||||
<section>
|
||||
<h2>TaxJar configuration</h2>
|
||||
<TaxJarConfigurationForm />
|
||||
</section>
|
||||
);
|
||||
};
|
26
apps/taxes/src/modules/taxes/tax-common-schema.ts
Normal file
26
apps/taxes/src/modules/taxes/tax-common-schema.ts
Normal 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,
|
||||
});
|
26
apps/taxes/src/modules/taxes/tax-context.ts
Normal file
26
apps/taxes/src/modules/taxes/tax-context.ts
Normal 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 };
|
||||
};
|
38
apps/taxes/src/modules/taxes/tax-line-resolver.ts
Normal file
38
apps/taxes/src/modules/taxes/tax-line-resolver.ts
Normal 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,
|
||||
};
|
8
apps/taxes/src/modules/taxes/tax-provider-error.ts
Normal file
8
apps/taxes/src/modules/taxes/tax-provider-error.ts
Normal 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);
|
||||
}
|
||||
}
|
12
apps/taxes/src/modules/taxes/tax-provider.ts
Normal file
12
apps/taxes/src/modules/taxes/tax-provider.ts
Normal 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>;
|
||||
}
|
6
apps/taxes/src/modules/taxes/tax-webhook.ts
Normal file
6
apps/taxes/src/modules/taxes/tax-webhook.ts
Normal 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
|
||||
> {}
|
22
apps/taxes/src/modules/taxes/types.ts
Normal file
22
apps/taxes/src/modules/taxes/types.ts
Normal 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;
|
||||
};
|
117
apps/taxes/src/modules/trpc/protected-client-procedure.ts
Normal file
117
apps/taxes/src/modules/trpc/protected-client-procedure.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
12
apps/taxes/src/modules/trpc/trpc-app-router.ts
Normal file
12
apps/taxes/src/modules/trpc/trpc-app-router.ts
Normal 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;
|
44
apps/taxes/src/modules/trpc/trpc-client.ts
Normal file
44
apps/taxes/src/modules/trpc/trpc-client.ts
Normal 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,
|
||||
});
|
13
apps/taxes/src/modules/trpc/trpc-context.ts
Normal file
13
apps/taxes/src/modules/trpc/trpc-context.ts
Normal 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>;
|
13
apps/taxes/src/modules/trpc/trpc-server.ts
Normal file
13
apps/taxes/src/modules/trpc/trpc-server.ts
Normal 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;
|
14
apps/taxes/src/modules/ui/app-container.tsx
Normal file
14
apps/taxes/src/modules/ui/app-container.tsx
Normal 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>;
|
||||
};
|
19
apps/taxes/src/modules/ui/app-grid.tsx
Normal file
19
apps/taxes/src/modules/ui/app-grid.tsx
Normal 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>;
|
||||
};
|
11
apps/taxes/src/modules/ui/app-layout.tsx
Normal file
11
apps/taxes/src/modules/ui/app-layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
13
apps/taxes/src/modules/ui/app-link.tsx
Normal file
13
apps/taxes/src/modules/ui/app-link.tsx
Normal 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>
|
||||
);
|
||||
};
|
18
apps/taxes/src/modules/ui/app-paper.tsx
Normal file
18
apps/taxes/src/modules/ui/app-paper.tsx
Normal 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>
|
||||
);
|
||||
};
|
23
apps/taxes/src/modules/ui/app-tab-nav-button.tsx
Normal file
23
apps/taxes/src/modules/ui/app-tab-nav-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
51
apps/taxes/src/modules/ui/instructions.tsx
Normal file
51
apps/taxes/src/modules/ui/instructions.tsx
Normal 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's configured tax provider for order & checkout tax
|
||||
calculations.
|
||||
</li>
|
||||
</ol>
|
||||
</Typography>
|
||||
</div>
|
||||
</AppPaper>
|
||||
);
|
||||
};
|
88
apps/taxes/src/pages/_app.tsx
Normal file
88
apps/taxes/src/pages/_app.tsx
Normal 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);
|
28
apps/taxes/src/pages/api/manifest.ts
Normal file
28
apps/taxes/src/pages/api/manifest.ts
Normal 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
Loading…
Reference in a new issue