diff --git a/apps/avatax/app-sdk-handlers/fetch-remote-jwks.ts b/apps/avatax/app-sdk-handlers/fetch-remote-jwks.ts new file mode 100644 index 000000000..d59a62a16 --- /dev/null +++ b/apps/avatax/app-sdk-handlers/fetch-remote-jwks.ts @@ -0,0 +1,14 @@ +export const getJwksUrlFromSaleorApiUrl = (saleorApiUrl: string): string => + `${new URL(saleorApiUrl).origin}/.well-known/jwks.json`; + +export const fetchRemoteJwks = async (saleorApiUrl: string) => { + try { + const jwksResponse = await fetch(getJwksUrlFromSaleorApiUrl(saleorApiUrl)); + + const jwksText = await jwksResponse.text(); + + return jwksText; + } catch (err) { + throw err; + } +}; diff --git a/apps/avatax/app-sdk-handlers/next/saleor-webhooks/process-saleor-webhook.ts b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/process-saleor-webhook.ts new file mode 100644 index 000000000..381570c22 --- /dev/null +++ b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/process-saleor-webhook.ts @@ -0,0 +1,198 @@ +import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; + +import { NextRequest } from "next/server"; +import { APL, AuthData } from "@saleor/app-sdk/APL"; +import { getOtelTracer } from "@saleor/apps-otel/src/otel-tracer"; +import { parseSchemaVersion } from "@saleor/webhook-utils/src/parse-schema-version"; +import { verifySignatureWithJwks } from "@saleor/app-sdk/verify-signature"; +import { getBaseUrl, getSaleorHeaders } from "../../utils"; +import { fetchRemoteJwks } from "../../fetch-remote-jwks"; + +export type SaleorWebhookError = + | "OTHER" + | "MISSING_HOST_HEADER" + | "MISSING_DOMAIN_HEADER" + | "MISSING_API_URL_HEADER" + | "MISSING_EVENT_HEADER" + | "MISSING_PAYLOAD_HEADER" + | "MISSING_SIGNATURE_HEADER" + | "MISSING_REQUEST_BODY" + | "WRONG_EVENT" + | "NOT_REGISTERED" + | "SIGNATURE_VERIFICATION_FAILED" + | "WRONG_METHOD" + | "CANT_BE_PARSED" + | "CONFIGURATION_ERROR"; + +export class WebhookError extends Error { + errorType: SaleorWebhookError = "OTHER"; + + constructor(message: string, errorType: SaleorWebhookError) { + super(message); + if (errorType) { + this.errorType = errorType; + } + Object.setPrototypeOf(this, WebhookError.prototype); + } +} + +export type WebhookContext = { + baseUrl: string; + event: string; + payload: T; + authData: AuthData; + /** For Saleor < 3.15 it will be null. */ + schemaVersion: number | null; +}; + +interface ProcessSaleorWebhookArgs { + req: NextRequest; + apl: APL; + allowedEvent: string; +} + +type ProcessSaleorWebhook = ( + props: ProcessSaleorWebhookArgs, +) => Promise>; + +/** + * Perform security checks on given request and return WebhookContext object. + * In case of validation issues, instance of the WebhookError will be thrown. + * + * @returns WebhookContext + */ +export const processSaleorWebhook: ProcessSaleorWebhook = async ({ + req, + apl, + allowedEvent, +}: ProcessSaleorWebhookArgs): Promise> => { + const tracer = getOtelTracer(); + + return tracer.startActiveSpan( + "processSaleorWebhook", + { + kind: SpanKind.INTERNAL, + attributes: { + allowedEvent, + }, + }, + async (span) => { + try { + if (req.method !== "POST") { + throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD"); + } + + const { event, signature, saleorApiUrl } = getSaleorHeaders(req.headers); + const baseUrl = getBaseUrl(req.headers); + + if (!baseUrl) { + throw new WebhookError("Missing host header", "MISSING_HOST_HEADER"); + } + + if (!saleorApiUrl) { + throw new WebhookError("Missing saleor-api-url header", "MISSING_API_URL_HEADER"); + } + + if (!event) { + throw new WebhookError("Missing saleor-event header", "MISSING_EVENT_HEADER"); + } + + const expected = allowedEvent.toLowerCase(); + + if (event !== expected) { + throw new WebhookError( + `Wrong incoming request event: ${event}. Expected: ${expected}`, + "WRONG_EVENT", + ); + } + + if (!signature) { + throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER"); + } + + const rawBody = await req.text(); + + if (!rawBody) { + throw new WebhookError("Missing request body", "MISSING_REQUEST_BODY"); + } + + let parsedBody: unknown & { version?: string | null }; + + try { + parsedBody = JSON.parse(rawBody); + } catch { + throw new WebhookError("Request body can't be parsed", "CANT_BE_PARSED"); + } + + let parsedSchemaVersion: number | null = null; + + try { + parsedSchemaVersion = parseSchemaVersion(parsedBody.version); + } catch {} + + /** + * Verify if the app is properly installed for given Saleor API URL + */ + const authData = await apl.get(saleorApiUrl); + + if (!authData) { + throw new WebhookError( + `Can't find auth data for ${saleorApiUrl}. Please register the application`, + "NOT_REGISTERED", + ); + } + + /** + * Verify payload signature + * + * TODO: Add test for repeat verification scenario + */ + try { + if (!authData.jwks) { + throw new Error("JWKS not found in AuthData"); + } + + await verifySignatureWithJwks(authData.jwks, signature, rawBody); + } catch { + const newJwks = await fetchRemoteJwks(authData.saleorApiUrl).catch((e) => { + throw new WebhookError("Fetching remote JWKS failed", "SIGNATURE_VERIFICATION_FAILED"); + }); + + try { + await verifySignatureWithJwks(newJwks, signature, rawBody); + + await apl.set({ ...authData, jwks: newJwks }); + } catch { + throw new WebhookError( + "Request signature check failed", + "SIGNATURE_VERIFICATION_FAILED", + ); + } + } + + span.setStatus({ + code: SpanStatusCode.OK, + }); + + return { + baseUrl, + event, + payload: parsedBody as T, + authData, + schemaVersion: parsedSchemaVersion, + }; + } catch (err) { + const message = (err as Error)?.message ?? "Unknown error"; + + span.setStatus({ + code: SpanStatusCode.ERROR, + message, + }); + + throw err; + } finally { + span.end(); + } + }, + ); +}; diff --git a/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-sync-webhook.ts b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-sync-webhook.ts new file mode 100644 index 000000000..bbf69de07 --- /dev/null +++ b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-sync-webhook.ts @@ -0,0 +1,37 @@ +import { NextWebhookApiHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook"; +import { buildSyncWebhookResponsePayload } from "./sync-webhook-response-builder"; +import { SyncWebhookEventType } from "@saleor/app-sdk/types"; + +type InjectedContext = { + buildResponse: typeof buildSyncWebhookResponsePayload; +}; + +export class SaleorSyncWebhook< + TPayload = unknown, + TEvent extends SyncWebhookEventType = SyncWebhookEventType, +> extends SaleorWebhook> { + readonly event: TEvent; + + protected readonly eventType = "sync" as const; + + protected extraContext = { + buildResponse: buildSyncWebhookResponsePayload, + }; + + constructor(configuration: WebhookConfig) { + super(configuration); + + this.event = configuration.event; + } + + createHandler( + handlerFn: NextWebhookApiHandler< + TPayload, + { + buildResponse: typeof buildSyncWebhookResponsePayload; + } + >, + ) { + return super.createHandler(handlerFn); + } +} diff --git a/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts new file mode 100644 index 000000000..00e89fba2 --- /dev/null +++ b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/saleor-webhook.ts @@ -0,0 +1,201 @@ +import { ASTNode } from "graphql"; + +import { + processSaleorWebhook, + SaleorWebhookError, + WebhookContext, + WebhookError, +} from "./process-saleor-webhook"; +import { APL } from "@saleor/app-sdk/APL"; +import { + AsyncWebhookEventType, + SyncWebhookEventType, + WebhookManifest, +} from "@saleor/app-sdk/types"; +import { gqlAstToString } from "../../utils"; +import { NextRequest, NextResponse } from "next/server"; + +export interface WebhookConfig { + name?: string; + webhookPath: string; + event: Event; + isActive?: boolean; + apl: APL; + onError?(error: WebhookError | Error, req: NextRequest): void; + formatErrorResponse?( + error: WebhookError | Error, + req: NextRequest, + ): Promise<{ + code: number; + body: object | string; + }>; + query: string | ASTNode; + /** + * @deprecated will be removed in 0.35.0, use query field instead + */ + subscriptionQueryAst?: ASTNode; +} + +export const WebhookErrorCodeMap: Record = { + OTHER: 500, + MISSING_HOST_HEADER: 400, + MISSING_DOMAIN_HEADER: 400, + MISSING_API_URL_HEADER: 400, + MISSING_EVENT_HEADER: 400, + MISSING_PAYLOAD_HEADER: 400, + MISSING_SIGNATURE_HEADER: 400, + MISSING_REQUEST_BODY: 400, + WRONG_EVENT: 400, + NOT_REGISTERED: 401, + SIGNATURE_VERIFICATION_FAILED: 401, + WRONG_METHOD: 405, + CANT_BE_PARSED: 400, + CONFIGURATION_ERROR: 500, +}; + +export type NextWebhookApiHandler = ( + req: NextRequest, + ctx: WebhookContext & TExtras, +) => Promise; + +export abstract class SaleorWebhook< + TPayload = unknown, + TExtras extends Record = {}, +> { + protected abstract eventType: "async" | "sync"; + + protected extraContext?: TExtras; + + name: string; + + webhookPath: string; + + query: string | ASTNode; + + event: AsyncWebhookEventType | SyncWebhookEventType; + + isActive?: boolean; + + apl: APL; + + onError: WebhookConfig["onError"]; + + formatErrorResponse: WebhookConfig["formatErrorResponse"]; + + protected constructor(configuration: WebhookConfig) { + const { + name, + webhookPath, + event, + query, + apl, + isActive = true, + subscriptionQueryAst, + } = configuration; + + this.name = name || `${event} webhook`; + /** + * Fallback subscriptionQueryAst to avoid breaking changes + * + * TODO Remove in 0.35.0 + */ + this.query = query ?? subscriptionQueryAst; + this.webhookPath = webhookPath; + this.event = event; + this.isActive = isActive; + this.apl = apl; + this.onError = configuration.onError; + this.formatErrorResponse = configuration.formatErrorResponse; + } + + private getTargetUrl(baseUrl: string) { + return new URL(this.webhookPath, baseUrl).href; + } + + /** + * Returns synchronous event manifest for this webhook. + * + * @param baseUrl Base URL used by your application + * @returns WebhookManifest + */ + getWebhookManifest(baseUrl: string): WebhookManifest { + const manifestBase: Omit = { + query: typeof this.query === "string" ? this.query : gqlAstToString(this.query), + name: this.name, + targetUrl: this.getTargetUrl(baseUrl), + isActive: this.isActive, + }; + + switch (this.eventType) { + case "async": + return { + ...manifestBase, + asyncEvents: [this.event as AsyncWebhookEventType], + }; + case "sync": + return { + ...manifestBase, + syncEvents: [this.event as SyncWebhookEventType], + }; + default: { + throw new Error("Class extended incorrectly"); + } + } + } + + /** + * Wraps provided function, to ensure incoming request comes from registered Saleor instance. + * Also provides additional `context` object containing typed payload and request properties. + */ + createHandler(handlerFn: NextWebhookApiHandler) { + return async (req: NextRequest): Promise => { + await processSaleorWebhook({ + req, + apl: this.apl, + allowedEvent: this.event, + }) + .then(async (context) => { + return handlerFn(req, { ...(this.extraContext ?? ({} as TExtras)), ...context }); + }) + .catch(async (e) => { + if (e instanceof WebhookError) { + if (this.onError) { + this.onError(e, req); + } + + if (this.formatErrorResponse) { + const { code, body } = await this.formatErrorResponse(e, req); + + return NextResponse.json(body, { status: code }); + } + + return NextResponse.json( + { + error: { + type: e.errorType, + message: e.message, + }, + }, + { + status: 400, + }, + ); + } + + if (this.onError) { + this.onError(e, req); + } + + if (this.formatErrorResponse) { + const { code, body } = await this.formatErrorResponse(e, req); + + return NextResponse.json(body, { status: 400 }); + } + + return new NextResponse(null, { status: 500 }); + }); + + return new NextResponse(null); + }; + } +} diff --git a/apps/avatax/app-sdk-handlers/next/saleor-webhooks/sync-webhook-response-builder.ts b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/sync-webhook-response-builder.ts new file mode 100644 index 000000000..50f83f370 --- /dev/null +++ b/apps/avatax/app-sdk-handlers/next/saleor-webhooks/sync-webhook-response-builder.ts @@ -0,0 +1,171 @@ +import { SyncWebhookEventType } from "@saleor/app-sdk/types"; + +export type SyncWebhookResponsesMap = { + CHECKOUT_CALCULATE_TAXES: { + shipping_price_gross_amount: number; + shipping_price_net_amount: number; + shipping_tax_rate: number; + lines: Array<{ + total_gross_amount: number; + total_net_amount: number; + tax_rate: number; + }>; + }; + CHECKOUT_FILTER_SHIPPING_METHODS: { + excluded_methods: Array<{ + id: string; + reason?: string; + }>; + }; + ORDER_CALCULATE_TAXES: SyncWebhookResponsesMap["CHECKOUT_CALCULATE_TAXES"]; + ORDER_FILTER_SHIPPING_METHODS: SyncWebhookResponsesMap["CHECKOUT_FILTER_SHIPPING_METHODS"]; + SHIPPING_LIST_METHODS_FOR_CHECKOUT: Array<{ + id: string; + name?: string; + amount: number; + currency: string; // or enum? + /** + * Integer + */ + maximum_delivery_days?: number; + }>; + TRANSACTION_CHARGE_REQUESTED: { + pspReference: string; + result?: "CHARGE_SUCCESS" | "CHARGE_FAILURE"; + amount?: number; + time?: string; + externalUrl?: string; + message?: string; + }; + TRANSACTION_REFUND_REQUESTED: { + pspReference: string; + result?: "REFUND_SUCCESS" | "REFUND_FAILURE"; + amount?: number; + time?: string; + externalUrl?: string; + message?: string; + }; + TRANSACTION_CANCELATION_REQUESTED: { + pspReference: string; + result?: "CANCEL_SUCCESS" | "CANCEL_FAILURE"; + amount?: number; + time?: string; + externalUrl?: string; + message?: string; + }; + PAYMENT_GATEWAY_INITIALIZE_SESSION: { + data: unknown; + }; + TRANSACTION_INITIALIZE_SESSION: { + pspReference?: string; + data?: unknown; + result: + | "CHARGE_SUCCESS" + | "CHARGE_FAILURE" + | "CHARGE_REQUESTED" + | "CHARGE_ACTION_REQUIRED" + | "AUTHORIZATION_SUCCESS" + | "AUTHORIZATION_FAILURE" + | "AUTHORIZATION_REQUESTED" + | "AUTHORIZATION_ACTION_REQUIRED"; + amount: number; + time?: string; + externalUrl?: string; + message?: string; + }; + TRANSACTION_PROCESS_SESSION: { + pspReference?: string; + data?: unknown; + result: + | "CHARGE_SUCCESS" + | "CHARGE_FAILURE" + | "CHARGE_REQUESTED" + | "CHARGE_ACTION_REQUIRED" + | "AUTHORIZATION_SUCCESS" + | "AUTHORIZATION_FAILURE" + | "AUTHORIZATION_REQUESTED" + | "AUTHORIZATION_ACTION_REQUIRED"; + amount: number; + time?: string; + externalUrl?: string; + message?: string; + }; + PAYMENT_METHOD_PROCESS_TOKENIZATION_SESSION: + | { + result: "SUCCESSFULLY_TOKENIZED"; + id: string; + data: unknown; + } + | { + result: "ADDITIONAL_ACTION_REQUIRED"; + id: string; + data: unknown; + } + | { + result: "PENDING"; + data: unknown; + } + | { + result: "FAILED_TO_TOKENIZE"; + error: string; + }; + PAYMENT_METHOD_INITIALIZE_TOKENIZATION_SESSION: + | { + result: "SUCCESSFULLY_TOKENIZED"; + id: string; + data: unknown; + } + | { + result: "ADDITIONAL_ACTION_REQUIRED"; + id: string; + data: unknown; + } + | { + result: "PENDING"; + data: unknown; + } + | { + result: "FAILED_TO_TOKENIZE"; + error: string; + }; + PAYMENT_GATEWAY_INITIALIZE_TOKENIZATION_SESSION: + | { + result: "SUCCESSFULLY_INITIALIZED"; + data: unknown; + } + | { + result: "FAILED_TO_INITIALIZE"; + error: string; + }; + STORED_PAYMENT_METHOD_DELETE_REQUESTED: + | { + result: "SUCCESSFULLY_DELETED"; + } + | { + result: "FAILED_TO_DELETE"; + error: string; + }; + LIST_STORED_PAYMENT_METHODS: { + paymentMethods: Array<{ + id: string; + supportedPaymentFlows: Array<"INTERACTIVE">; + type: string; + creditCardInfo?: { + brand: string; + lastDigits: string; + expMonth: string; + expYear: string; + firstDigits?: string; + }; + name?: string; + data?: unknown; + }>; + }; +}; + +/** + * Identity function, but it works on Typescript level to pick right payload based on first param + */ +export const buildSyncWebhookResponsePayload = ( + payload: SyncWebhookResponsesMap[E], +): SyncWebhookResponsesMap[E] => payload; diff --git a/apps/avatax/app-sdk-handlers/utils.ts b/apps/avatax/app-sdk-handlers/utils.ts new file mode 100644 index 000000000..071f9c7a2 --- /dev/null +++ b/apps/avatax/app-sdk-handlers/utils.ts @@ -0,0 +1,47 @@ +import { ASTNode, print } from "graphql"; +import { + SALEOR_API_URL_HEADER, + SALEOR_AUTHORIZATION_BEARER_HEADER, + SALEOR_DOMAIN_HEADER, + SALEOR_EVENT_HEADER, + SALEOR_SCHEMA_VERSION, + SALEOR_SIGNATURE_HEADER, +} from "@saleor/app-sdk/const"; + +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 const getBaseUrl = (headers: Headers): string => { + const xForwardedProto = headers.get("x-forwarded-proto") ?? "http"; + + const xForwardedProtos = Array.isArray(xForwardedProto) + ? xForwardedProto.join(",") + : xForwardedProto; + + const protocols = xForwardedProtos.split(","); + // prefer https over other protocols + const protocol = protocols.find((el) => el === "https") || protocols[0]; + + return `${protocol}://${headers.get("host")}`; +}; + +const toStringOrUndefined = (value: string | string[] | undefined | null) => + value ? value.toString() : undefined; + +const toFloatOrNull = (value: string | string[] | undefined | null) => + value ? parseFloat(value.toString()) : null; + +/** + * Extracts Saleor-specific headers from the response. + */ +export const getSaleorHeaders = (headers: Headers) => ({ + domain: toStringOrUndefined(headers.get(SALEOR_DOMAIN_HEADER)), + authorizationBearer: toStringOrUndefined(headers.get(SALEOR_AUTHORIZATION_BEARER_HEADER)), + signature: toStringOrUndefined(headers.get(SALEOR_SIGNATURE_HEADER)), + event: toStringOrUndefined(headers.get(SALEOR_EVENT_HEADER)), + saleorApiUrl: toStringOrUndefined(headers.get(SALEOR_API_URL_HEADER)), + schemaVersion: toFloatOrNull(headers.get(SALEOR_SCHEMA_VERSION)), +}); diff --git a/apps/avatax/next-env.d.ts b/apps/avatax/next-env.d.ts index 4f11a03dc..fd36f9494 100644 --- a/apps/avatax/next-env.d.ts +++ b/apps/avatax/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts new file mode 100644 index 000000000..185f94fed --- /dev/null +++ b/apps/avatax/src/app/webhooks/checkout-calculate-taxes-2/route.ts @@ -0,0 +1,149 @@ +import * as Sentry from "@sentry/nextjs"; +import { captureException } from "@sentry/nextjs"; + +import { AppConfigExtractor } from "@/lib/app-config-extractor"; +import { AppConfigurationLogger } from "@/lib/app-configuration-logger"; +import { SubscriptionPayloadErrorChecker } from "@/lib/error-utils"; +import { createLogger } from "@/logger"; +import { CalculateTaxesUseCase } from "@/modules/calculate-taxes/use-case/calculate-taxes.use-case"; +import { AvataxInvalidAddressError } from "@/modules/taxes/tax-error"; +import { checkoutCalculateTaxesSyncWebhook2 } from "@/wh"; +import { NextResponse } from "next/server"; + +const logger = createLogger("checkoutCalculateTaxesSyncWebhook"); + +const subscriptionErrorChecker = new SubscriptionPayloadErrorChecker(logger, captureException); +const useCase = new CalculateTaxesUseCase({ + configExtractor: new AppConfigExtractor(), +}); + +const handler = checkoutCalculateTaxesSyncWebhook2.createHandler( + async (req, ctx): Promise => { + try { + const { payload, authData } = ctx; + + subscriptionErrorChecker.checkPayload(payload); + + logger.info("Tax base payload for checkout calculate taxes", { + payload: payload.taxBase, + }); + + logger.info("Handler for CHECKOUT_CALCULATE_TAXES webhook called"); + + const appMetadata = payload.recipient?.privateMetadata ?? []; + const channelSlug = payload.taxBase.channel.slug; + + const configExtractor = new AppConfigExtractor(); + + const config = configExtractor + .extractAppConfigFromPrivateMetadata(appMetadata) + .map((config) => { + try { + new AppConfigurationLogger(logger).logConfiguration(config, channelSlug); + } catch (e) { + captureException( + new AppConfigExtractor.LogConfigurationMetricError( + "Failed to log configuration metric", + { + cause: e, + }, + ), + ); + } + + return config; + }); + + if (config.isErr()) { + logger.warn("Failed to extract app config from metadata", { error: config.error }); + + return NextResponse.json( + { + message: `App configuration is broken for checkout: ${payload.taxBase.sourceObject.id}`, + }, + { status: 400 }, + ); + } + + const res = await useCase.calculateTaxes(payload, authData).then((result) => { + return result.match( + (value) => { + return NextResponse.json(ctx.buildResponse(value), { + status: 200, + }); + }, + (err) => { + logger.warn("Error calculating taxes", { error: err }); + + switch (err.constructor) { + case CalculateTaxesUseCase.FailedCalculatingTaxesError: { + return NextResponse.json( + { + message: `Failed to calculate taxes for checkout: ${payload.taxBase.sourceObject.id}`, + }, + { + status: 500, + }, + ); + } + case CalculateTaxesUseCase.ConfigBrokenError: { + return NextResponse.json( + { + message: `Failed to calculate taxes due to invalid configuration for checkout: ${payload.taxBase.sourceObject.id}`, + }, + { status: 400 }, + ); + } + case CalculateTaxesUseCase.ExpectedIncompletePayloadError: { + return NextResponse.json( + { + message: `Taxes can't be calculated due to incomplete payload for checkout: ${payload.taxBase.sourceObject.id}`, + }, + { status: 400 }, + ); + } + case CalculateTaxesUseCase.UnhandledError: { + captureException(err); + + return NextResponse.json( + { + message: `Failed to calculate taxes (Unhandled error) for checkout: ${payload.taxBase.sourceObject.id}`, + }, + { + status: 500, + }, + ); + } + default: { + return NextResponse.json({}, { status: 500 }); + } + } + }, + ); + }); + + return res; + } catch (error) { + // todo this should be now available in usecase. Catch it from FailedCalculatingTaxesError + if (error instanceof AvataxInvalidAddressError) { + logger.warn( + "InvalidAppAddressError: App returns status 400 due to broken address configuration", + { error }, + ); + + return NextResponse.json( + { + message: "InvalidAppAddressError: Check address in app configuration", + }, + { status: 400 }, + ); + } + + Sentry.captureException(error); + + return NextResponse.json({ message: "Unhandled error" }, { status: 500 }); + } + }, +); + +export const POST = handler; diff --git a/apps/avatax/src/lib/app-metadata-cache.ts b/apps/avatax/src/lib/app-metadata-cache.ts index d8315f852..999341951 100644 --- a/apps/avatax/src/lib/app-metadata-cache.ts +++ b/apps/avatax/src/lib/app-metadata-cache.ts @@ -1,7 +1,10 @@ import { AsyncLocalStorage } from "async_hooks"; -import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; import { MetadataItem } from "../../generated/graphql"; import { createLogger } from "../logger"; +import { NextRequest, NextResponse } from "next/server"; +import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; + +type Handler = (req: NextRequest) => Promise; /** * Set global context that stores metadata from webhook payload. @@ -25,7 +28,7 @@ export class AppMetadataCache { return store.metadata; } - async wrap(fn: (...args: unknown[]) => unknown) { + async wrap(fn: any) { return this.als.run({ metadata: null }, fn); } @@ -48,4 +51,8 @@ export const wrapWithMetadataCache = (cache: AppMetadataCache) => (handler: Next }; }; +export const wrapWithMetadataCacheAppRouter = + (cache: AppMetadataCache) => (handler: Handler) => (req: NextRequest) => + cache.wrap(() => handler(req)); + export const metadataCache = new AppMetadataCache(); diff --git a/apps/avatax/src/pages/api/manifest.ts b/apps/avatax/src/pages/api/manifest.ts index 4a49a4049..0a55e0db6 100644 --- a/apps/avatax/src/pages/api/manifest.ts +++ b/apps/avatax/src/pages/api/manifest.ts @@ -7,6 +7,7 @@ import packageJson from "../../../package.json"; import { REQUIRED_SALEOR_VERSION } from "../../../saleor-app"; import { appWebhooks } from "../../../webhooks"; import { loggerContext } from "../../logger-context"; +import { checkoutCalculateTaxesSyncWebhook2 } from "@/wh"; export default wrapWithLoggerContext( withOtel( diff --git a/apps/avatax/src/wh.ts b/apps/avatax/src/wh.ts new file mode 100644 index 000000000..ce14ded13 --- /dev/null +++ b/apps/avatax/src/wh.ts @@ -0,0 +1,12 @@ +import { SaleorSyncWebhook } from "../app-sdk-handlers/next/saleor-webhooks/saleor-sync-webhook"; +import { CalculateTaxesPayload } from "@/modules/webhooks/payloads/calculate-taxes-payload"; +import { saleorApp } from "../saleor-app"; +import { UntypedCalculateTaxesDocument } from "../generated/graphql"; + +export const checkoutCalculateTaxesSyncWebhook2 = new SaleorSyncWebhook({ + name: "CheckoutCalculateTaxes2", + apl: saleorApp.apl, + event: "CHECKOUT_CALCULATE_TAXES", + query: UntypedCalculateTaxesDocument, + webhookPath: "/webhooks/checkout-calculate-taxes-2", +}); diff --git a/apps/avatax/tsconfig.json b/apps/avatax/tsconfig.json index 687ae8c17..ff311c19f 100644 --- a/apps/avatax/tsconfig.json +++ b/apps/avatax/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -16,9 +20,24 @@ "incremental": true, "baseUrl": ".", "paths": { - "@/*": ["src/*"] - } + "@/*": [ + "src/*" + ] + }, + "plugins": [ + { + "name": "next" + } + ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "next.config.js", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/apps/avatax/webhooks.ts b/apps/avatax/webhooks.ts index a8d797ef1..e1e371ba9 100644 --- a/apps/avatax/webhooks.ts +++ b/apps/avatax/webhooks.ts @@ -2,10 +2,12 @@ import { checkoutCalculateTaxesSyncWebhook } from "./src/modules/webhooks/defini import { orderCalculateTaxesSyncWebhook } from "./src/modules/webhooks/definitions/order-calculate-taxes"; import { orderCancelledAsyncWebhook } from "./src/modules/webhooks/definitions/order-cancelled"; import { orderConfirmedAsyncWebhook } from "./src/modules/webhooks/definitions/order-confirmed"; +import { checkoutCalculateTaxesSyncWebhook2 } from "@/wh"; export const appWebhooks = [ - checkoutCalculateTaxesSyncWebhook, + // checkoutCalculateTaxesSyncWebhook, orderCalculateTaxesSyncWebhook, orderCancelledAsyncWebhook, orderConfirmedAsyncWebhook, + checkoutCalculateTaxesSyncWebhook2, ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37d9f9db9..1a7a33aa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,7 +181,7 @@ importers: version: 10.43.1(@trpc/server@10.43.1) '@trpc/next': specifier: 10.43.1 - version: 10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/react-query@10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/server@10.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.43.1)(next@14.2.3(@opentelemetry/api@node_modules+@opentelemetry+api)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/react-query@10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/server@10.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.43.1)(next@14.2.3(@babel/core@7.24.7)(@opentelemetry/api@node_modules+@opentelemetry+api)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@trpc/react-query': specifier: 10.43.1 version: 10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/server@10.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -407,7 +407,7 @@ importers: version: 10.43.1(@trpc/server@10.43.1) '@trpc/next': specifier: 10.43.1 - version: 10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/react-query@10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/server@10.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.43.1)(next@14.2.3(@babel/core@7.24.7)(@opentelemetry/api@node_modules+@opentelemetry+api)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/react-query@10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/server@10.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/server@10.43.1)(next@14.2.3(@opentelemetry/api@node_modules+@opentelemetry+api)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@trpc/react-query': specifier: 10.43.1 version: 10.43.1(@tanstack/react-query@4.29.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@trpc/client@10.43.1(@trpc/server@10.43.1))(@trpc/server@10.43.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)