refactor: ♻️ use app-sdk syncwebhook

This commit is contained in:
Adrian Pilarczyk 2023-03-07 11:31:44 +01:00
parent 0d75d3a311
commit 9138d7debd
13 changed files with 186 additions and 355 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,9 +2,10 @@ 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 { ResponseTaxPayload } from "../../types";
import { AvataxConfig } from "./avatax-config";
const SHIPPING_ITEM_CODE = "Shipping";
@ -88,7 +89,7 @@ const prepareResponse = (transaction: TransactionModel): ResponseTaxPayload => {
shipping_price_gross_amount: shippingGrossAmount,
shipping_price_net_amount: shippingNetAmount,
// todo: add shipping tax rate
shipping_tax_rate: "0",
shipping_tax_rate: 0,
lines:
productLines?.map((line) => {
const lineTaxCalculated = line.taxCalculated ?? 0;
@ -98,7 +99,7 @@ const prepareResponse = (transaction: TransactionModel): ResponseTaxPayload => {
total_gross_amount: lineTotalGrossAmount,
total_net_amount: lineTotalNetAmount,
// todo: add tax rate
tax_rate: "0",
tax_rate: 0,
};
}) ?? [],
};

View file

@ -5,14 +5,24 @@ import {
TaxBaseLineFragment,
TaxDiscountFragment,
} from "../../../../../generated/graphql";
import { FetchTaxesLinePayload, ResponseTaxPayload } from "../../types";
import { ChannelConfig } from "../../../channels-configuration/channels-config";
import { taxLineResolver } from "../../tax-line-resolver";
import { ResponseTaxPayload } from "../../types";
const formatCalculatedAmount = (amount: number) => {
return Number(amount.toFixed(2));
};
type FetchTaxesLinePayload = {
id: string;
quantity: number;
taxCode?: string | null;
discount: number;
chargeTaxes: boolean;
unitAmount: number;
totalAmount: number;
};
const prepareLinesWithDiscountPayload = (
lines: Array<TaxBaseLineFragment>,
discounts: Array<TaxDiscountFragment>
@ -66,7 +76,7 @@ const prepareResponse = (
return {
shipping_price_gross_amount: formatCalculatedAmount(shippingPriceGross),
shipping_price_net_amount: formatCalculatedAmount(shippingPriceNet),
shipping_tax_rate: String(shippingTaxRate),
shipping_tax_rate: 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) => {
@ -75,11 +85,11 @@ const prepareResponse = (
? 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";
const taxRate = lineTax ? lineTax.combined_tax_rate : 0;
return {
total_gross_amount: formatCalculatedAmount(totalGrossAmount),
total_net_amount: formatCalculatedAmount(totalNetAmount),
tax_rate: taxRate,
tax_rate: taxRate ?? 0,
};
}),
};

View file

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

View file

@ -1,22 +1,3 @@
type LineTaxResponsePayload = {
total_gross_amount: number;
total_net_amount: number;
tax_rate: string;
};
import { SyncWebhookResponsesMap } from "@saleor/app-sdk/handlers/next";
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;
};
export type ResponseTaxPayload = SyncWebhookResponsesMap["ORDER_CALCULATE_TAXES"];

View file

@ -1,27 +1,95 @@
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
import { UntypedCalculateTaxesDocument } from "../../../../generated/graphql";
import { saleorApp } from "../../../../saleor-app";
import { createClient } from "../../../lib/graphql";
import { createLogger } from "../../../lib/logger";
import { withTaxesWebhook } from "../../../lib/saleor/with-taxes-webhook";
import { calculateTaxesPayloadSchema, ExpectedWebhookPayload } from "../../../lib/saleor/schema";
import { GetChannelsConfigurationService } from "../../../modules/channels-configuration/get-channels-configuration.service";
import { GetProvidersConfigurationService } from "../../../modules/providers-configuration/get-providers-configuration.service";
import { ActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
import { TaxSaleorSyncWebhook } from "../../../modules/taxes/tax-webhook";
export const checkoutCalculateTaxesSyncWebhook = new TaxSaleorSyncWebhook({
export const config = {
api: {
bodyParser: false,
},
};
export const checkoutCalculateTaxesSyncWebhook = new SaleorSyncWebhook<ExpectedWebhookPayload>({
name: "CheckoutCalculateTaxes",
apl: saleorApp.apl,
syncEvent: "CHECKOUT_CALCULATE_TAXES",
subscriptionQueryAst: UntypedCalculateTaxesDocument,
event: "CHECKOUT_CALCULATE_TAXES",
query: UntypedCalculateTaxesDocument,
webhookPath: "/api/webhooks/checkout-calculate-taxes",
});
const handler = withTaxesWebhook(async (payload, config, res) => {
const logger = createLogger({ fn: "Inside CHECKOUT_CALCULATE_TAXES handler" });
logger.info("Handler called");
const { provider, channel } = config;
const taxProvider = new ActiveTaxProvider(provider);
const calculatedTaxes = await taxProvider.calculate(payload.taxBase, channel);
export default checkoutCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx) => {
const logger = createLogger({ event: ctx.event });
const { authData, payload } = ctx;
logger.info({ payload }, "Handler called with payload");
if (!authData) {
logger.error("Auth data not found");
logger.info("Returning no data");
return res.send({});
}
logger.info("Parsing payload...");
const validation = calculateTaxesPayloadSchema.safeParse(payload);
if (!validation.success) {
logger.error({ error: validation.error.message }, "Payload is invalid");
logger.info("Returning no data");
return res.send({});
}
const { data } = validation;
logger.info({ data }, "Payload is valid.");
try {
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("Returning no data");
return res.send({});
}
const providerInstance = providersConfig.find(
(instance) => instance.id === channelConfig.providerInstanceId
);
if (!providerInstance) {
logger.error(`Channel (${channelSlug}) providerInstanceId does not match any providers`);
logger.info("Returning no data");
return res.send({});
}
const taxProvider = new ActiveTaxProvider(providerInstance);
const calculatedTaxes = await taxProvider.calculate(data.taxBase, channelConfig);
logger.info({ calculatedTaxes }, "Taxes calculated");
return res.status(200).json(calculatedTaxes);
return res.send(ctx.buildResponse(calculatedTaxes));
} catch (error) {
logger.error({ error }, "Error while calculating taxes");
logger.info("Returning no data");
return res.send({});
}
});
export default checkoutCalculateTaxesSyncWebhook.createHandler(handler);

View file

@ -1,27 +1,95 @@
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
import { UntypedCalculateTaxesDocument } from "../../../../generated/graphql";
import { saleorApp } from "../../../../saleor-app";
import { createClient } from "../../../lib/graphql";
import { createLogger } from "../../../lib/logger";
import { withTaxesWebhook } from "../../../lib/saleor/with-taxes-webhook";
import { calculateTaxesPayloadSchema, ExpectedWebhookPayload } from "../../../lib/saleor/schema";
import { GetChannelsConfigurationService } from "../../../modules/channels-configuration/get-channels-configuration.service";
import { GetProvidersConfigurationService } from "../../../modules/providers-configuration/get-providers-configuration.service";
import { ActiveTaxProvider } from "../../../modules/taxes/active-tax-provider";
import { TaxSaleorSyncWebhook } from "../../../modules/taxes/tax-webhook";
export const orderCalculateTaxesSyncWebhook = new TaxSaleorSyncWebhook({
export const config = {
api: {
bodyParser: false,
},
};
export const orderCalculateTaxesSyncWebhook = new SaleorSyncWebhook<ExpectedWebhookPayload>({
name: "OrderCalculateTaxes",
apl: saleorApp.apl,
syncEvent: "ORDER_CALCULATE_TAXES",
subscriptionQueryAst: UntypedCalculateTaxesDocument,
event: "ORDER_CALCULATE_TAXES",
query: UntypedCalculateTaxesDocument,
webhookPath: "/api/webhooks/order-calculate-taxes",
});
const handler = withTaxesWebhook(async (payload, config, res) => {
const logger = createLogger({});
logger.info("Inside ORDER_CALCULATE_TAXES handler");
const { provider, channel } = config;
const taxProvider = new ActiveTaxProvider(provider);
const calculatedTaxes = await taxProvider.calculate(payload.taxBase, channel);
export default orderCalculateTaxesSyncWebhook.createHandler(async (req, res, ctx) => {
const logger = createLogger({ event: ctx.event });
const { authData, payload } = ctx;
logger.info({ payload }, "Handler called with payload");
if (!authData) {
logger.error("Auth data not found");
logger.info("Returning no data");
return res.status(200).json({});
}
logger.info("Parsing payload...");
const validation = calculateTaxesPayloadSchema.safeParse(payload);
if (!validation.success) {
logger.error({ error: validation.error.message }, "Payload is invalid");
logger.info("Returning no data");
return res.status(200).json({});
}
const { data } = validation;
logger.info({ data }, "Payload is valid.");
try {
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("Returning no data");
return res.send({});
}
const providerInstance = providersConfig.find(
(instance) => instance.id === channelConfig.providerInstanceId
);
if (!providerInstance) {
logger.error(`Channel (${channelSlug}) providerInstanceId does not match any providers`);
logger.info("Returning no data");
return res.send({});
}
const taxProvider = new ActiveTaxProvider(providerInstance);
const calculatedTaxes = await taxProvider.calculate(data.taxBase, channelConfig);
logger.info({ calculatedTaxes }, "Taxes calculated");
return res.status(200).json(calculatedTaxes);
return res.send(ctx.buildResponse(calculatedTaxes));
} catch (error) {
logger.error({ error }, "Error while calculating taxes");
logger.info("Returning no data");
return res.send({});
}
});
export default orderCalculateTaxesSyncWebhook.createHandler(handler);