diff --git a/apps/taxes/graphql/fragments/AvataxOrderMetadata.graphql b/apps/taxes/graphql/fragments/AvataxOrderMetadata.graphql new file mode 100644 index 0000000..068f4a2 --- /dev/null +++ b/apps/taxes/graphql/fragments/AvataxOrderMetadata.graphql @@ -0,0 +1,5 @@ +fragment AvataxOrderMetadata on Order { + avataxEntityCode: metafield(key: "avataxEntityCode") + avataxTaxCalculationDate: metafield(key: "avataxTaxCalculationDate") + avataxDocumentCode: metafield(key: "avataxDocumentCode") +} diff --git a/apps/taxes/graphql/fragments/Money.graphql b/apps/taxes/graphql/fragments/Money.graphql new file mode 100644 index 0000000..0ce4dff --- /dev/null +++ b/apps/taxes/graphql/fragments/Money.graphql @@ -0,0 +1,4 @@ +fragment Money on Money { + currency + amount +} diff --git a/apps/taxes/graphql/subscriptions/OrderRefunded.graphql b/apps/taxes/graphql/subscriptions/OrderRefunded.graphql index 4c3d2c9..08132fe 100644 --- a/apps/taxes/graphql/subscriptions/OrderRefunded.graphql +++ b/apps/taxes/graphql/subscriptions/OrderRefunded.graphql @@ -1,10 +1,41 @@ +fragment Payment on Payment { + transactions { + kind + amount { + ...Money + } + } +} + fragment OrderRefundedSubscription on Order { id avataxId: metafield(key: "avataxId") + ...AvataxOrderMetadata channel { id slug } + lines { + productSku + } + totalRefunded { + ...Money + } + totalGrantedRefund { + ...Money + } + totalRefundPending { + ...Money + } + grantedRefunds { + amount { + ...Money + } + reason + } + payments { + ...Payment + } } fragment OrderRefundedEventSubscription on Event { diff --git a/apps/taxes/src/modules/avatax/avatax-client.ts b/apps/taxes/src/modules/avatax/avatax-client.ts index ed6b6c6..d681809 100644 --- a/apps/taxes/src/modules/avatax/avatax-client.ts +++ b/apps/taxes/src/modules/avatax/avatax-client.ts @@ -55,6 +55,8 @@ export type VoidTransactionArgs = { companyCode: string; }; +export type RefundTransactionParams = Parameters[0]; + export class AvataxClient { private client: Avatax; @@ -112,4 +114,8 @@ export class AvataxClient { filter: `code eq ${useCode}`, }); } + + async refundTransaction(params: RefundTransactionParams) { + return this.client.refundTransaction(params); + } } diff --git a/apps/taxes/src/modules/avatax/avatax-document-code-resolver.ts b/apps/taxes/src/modules/avatax/avatax-document-code-resolver.ts index d5d4991..4e48f80 100644 --- a/apps/taxes/src/modules/avatax/avatax-document-code-resolver.ts +++ b/apps/taxes/src/modules/avatax/avatax-document-code-resolver.ts @@ -4,7 +4,7 @@ export class AvataxDocumentCodeResolver { orderId, }: { avataxDocumentCode: string | null | undefined; - orderId: string; + orderId: string | undefined; }): string { /* * The value for "code" can be provided in the metadata. @@ -14,9 +14,12 @@ export class AvataxDocumentCodeResolver { const code = avataxDocumentCode ?? orderId; + if (!code) { + throw new Error("Order id or document code must be provided"); + } + /* * The requirement from AvaTax API is that document code is a string that must be between 1 and 20 characters long. - * // todo: document that its sliced */ return code.slice(0, 20); } diff --git a/apps/taxes/src/modules/avatax/avatax-webhook.service.ts b/apps/taxes/src/modules/avatax/avatax-webhook.service.ts index ec708ff..c2dea8a 100644 --- a/apps/taxes/src/modules/avatax/avatax-webhook.service.ts +++ b/apps/taxes/src/modules/avatax/avatax-webhook.service.ts @@ -1,8 +1,5 @@ import { AuthData } from "@saleor/app-sdk/APL"; -import { - OrderConfirmedSubscriptionFragment, - OrderRefundedSubscriptionFragment, -} from "../../../generated/graphql"; +import { OrderConfirmedSubscriptionFragment } from "../../../generated/graphql"; import { Logger, createLogger } from "../../lib/logger"; import { CalculateTaxesPayload } from "../../pages/api/webhooks/checkout-calculate-taxes"; import { OrderCancelledPayload } from "../../pages/api/webhooks/order-cancelled"; @@ -13,6 +10,7 @@ import { AvataxConfig, defaultAvataxConfig } from "./avatax-connection-schema"; import { AvataxCalculateTaxesAdapter } from "./calculate-taxes/avatax-calculate-taxes-adapter"; import { AvataxOrderCancelledAdapter } from "./order-cancelled/avatax-order-cancelled-adapter"; import { AvataxOrderConfirmedAdapter } from "./order-confirmed/avatax-order-confirmed-adapter"; +import { AvataxOrderRefundedAdapter } from "./order-refunded/avatax-order-refunded-adapter"; export class AvataxWebhookService implements ProviderWebhookService { config = defaultAvataxConfig; @@ -28,7 +26,6 @@ export class AvataxWebhookService implements ProviderWebhookService { }); const avataxClient = new AvataxClient(config); - refundOrder: (payload: OrderRefundedSubscriptionFragment) => Promise; this.config = config; this.client = avataxClient; } @@ -56,6 +53,8 @@ export class AvataxWebhookService implements ProviderWebhookService { } async refundOrder(payload: OrderRefundedPayload) { - // todo: implement + const adapter = new AvataxOrderRefundedAdapter(this.config); + + return adapter.send(payload); } } diff --git a/apps/taxes/src/modules/avatax/order-refunded/avatax-order-refunded-adapter.ts b/apps/taxes/src/modules/avatax/order-refunded/avatax-order-refunded-adapter.ts new file mode 100644 index 0000000..0598799 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-refunded/avatax-order-refunded-adapter.ts @@ -0,0 +1,89 @@ +import { RefundType } from "avatax/lib/enums/RefundType"; +import { z } from "zod"; +import { Logger, createLogger } from "../../../lib/logger"; +import { OrderRefundedPayload } from "../../../pages/api/webhooks/order-refunded"; +import { WebhookAdapter } from "../../taxes/tax-webhook-adapter"; +import { AvataxClient, RefundTransactionParams } from "../avatax-client"; +import { AvataxConfig, defaultAvataxConfig } from "../avatax-connection-schema"; +import { taxProviderUtils } from "../../taxes/tax-provider-utils"; + +class AvataxOrderRefundedPayloadTransformer { + private logger: Logger; + + constructor() { + this.logger = createLogger({ name: "AvataxOrderRefundedPayloadTransformer" }); + } + + transform(payload: OrderRefundedPayload, avataxConfig: AvataxConfig): RefundTransactionParams { + this.logger.debug( + { payload }, + "Transforming the Saleor payload for refunding order with AvaTax...", + ); + + const isFull = true; + + const transactionCode = z + .string() + .min(1, "Unable to refund transaction. Avatax id not found in order metadata") + .parse(payload.order?.avataxId); + + const baseParams: Pick = { + transactionCode, + companyCode: avataxConfig.companyCode ?? defaultAvataxConfig.companyCode, + }; + + if (!isFull) { + return { + ...baseParams, + model: { + refundType: RefundType.Partial, + refundDate: new Date(), + refundLines: payload.order?.lines?.map((line) => + // todo: replace with some other code + taxProviderUtils.resolveStringOrThrow(line.productSku), + ), + }, + }; + } + + return { + ...baseParams, + model: { + refundType: RefundType.Full, + refundDate: new Date(), + }, + }; + } +} + +export class AvataxOrderRefundedAdapter implements WebhookAdapter { + private logger: Logger; + + constructor(private readonly config: AvataxConfig) { + this.logger = createLogger({ name: "AvataxOrderRefundedAdapter" }); + } + + async send(payload: OrderRefundedPayload) { + this.logger.debug( + { payload }, + "Transforming the Saleor payload for refunding order with AvaTax...", + ); + + if (!this.config.isAutocommit) { + throw new Error( + "Unable to refund transaction. AvaTax can only refund commited transactions.", + ); + } + + const client = new AvataxClient(this.config); + const payloadTransformer = new AvataxOrderRefundedPayloadTransformer(); + const target = payloadTransformer.transform(payload, this.config); + + const response = await client.refundTransaction(target); + + this.logger.debug( + { response }, + `Succesfully refunded the transaction of id: ${target.transactionCode}`, + ); + } +} diff --git a/apps/taxes/src/modules/avatax/order-refunded/avatax-refunds-resolver.test.ts b/apps/taxes/src/modules/avatax/order-refunded/avatax-refunds-resolver.test.ts new file mode 100644 index 0000000..10d0b46 --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-refunded/avatax-refunds-resolver.test.ts @@ -0,0 +1,53 @@ +import { PaymentFragment, TransactionKind } from "../../../../generated/graphql"; +import { AvataxRefundsResolver } from "./avatax-refunds-resolver"; +import { expect, describe, it } from "vitest"; + +describe("AvataxRefundsResolver", () => { + it("returns transaction amounts for refunds", () => { + const resolver = new AvataxRefundsResolver(); + const mockPayments: PaymentFragment[] = [ + { + transactions: [ + { + kind: TransactionKind.Refund, + amount: { + amount: 20.0, + currency: "USD", + }, + }, + { + kind: TransactionKind.Capture, + amount: { + amount: 20.0, + currency: "USD", + }, + }, + ], + }, + { + transactions: [ + { + kind: TransactionKind.Refund, + amount: { + amount: 35.0, + currency: "USD", + }, + }, + ], + }, + ]; + + const refunds = resolver.resolve(mockPayments); + + expect(refunds).toEqual([ + { + amount: 20.0, + currency: "USD", + }, + { + amount: 35.0, + currency: "USD", + }, + ]); + }); +}); diff --git a/apps/taxes/src/modules/avatax/order-refunded/avatax-refunds-resolver.ts b/apps/taxes/src/modules/avatax/order-refunded/avatax-refunds-resolver.ts new file mode 100644 index 0000000..c8f17dc --- /dev/null +++ b/apps/taxes/src/modules/avatax/order-refunded/avatax-refunds-resolver.ts @@ -0,0 +1,11 @@ +import { PaymentFragment, TransactionKind } from "../../../../generated/graphql"; + +export class AvataxRefundsResolver { + resolve(payments: PaymentFragment[]) { + return payments + .flatMap( + (payment) => payment.transactions?.filter((t) => t.kind === TransactionKind.Refund) ?? [], + ) + .map((t) => t.amount); + } +} diff --git a/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts b/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts index a1e5fd1..4080937 100644 --- a/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts +++ b/apps/taxes/src/modules/taxjar/taxjar-webhook.service.ts @@ -48,9 +48,11 @@ export class TaxJarWebhookService implements ProviderWebhookService { async cancelOrder(payload: OrderCancelledEventSubscriptionFragment) { // todo: implement + this.logger.debug("cancelOrder not implement for TaxJar"); } async refundOrder(payload: OrderRefundedPayload) { // todo: implement + this.logger.debug("refundOrder not implement for TaxJar"); } } diff --git a/apps/taxes/src/pages/api/webhooks/order-refunded.ts b/apps/taxes/src/pages/api/webhooks/order-refunded.ts index 1e75020..017f35d 100644 --- a/apps/taxes/src/pages/api/webhooks/order-refunded.ts +++ b/apps/taxes/src/pages/api/webhooks/order-refunded.ts @@ -51,6 +51,6 @@ export default orderRefundedAsyncWebhook.createHandler(async (req, res, ctx) => return webhookResponse.success(); } catch (error) { - return webhookResponse.error(new Error("Error while refunding tax provider order")); + return webhookResponse.error(error); } });