Compare commits

...

6 commits

Author SHA1 Message Date
Adrian Pilarczyk
c302f41f3e feat: add createOrAdjustTransaction refund flow 2023-08-30 13:15:00 +02:00
Adrian Pilarczyk
2f8051bacc refactor: ♻️ extract AvataxAddressResolver from order-confirmed 2023-08-30 11:38:23 +02:00
Adrian Pilarczyk
5f82d274fd feat: 🚧 add work in progress refunds 2023-08-28 08:46:07 +02:00
Adrian Pilarczyk
542bdcaa84 feat: add boilerplate for order_refunded 2023-08-28 08:45:44 +02:00
Adrian Pilarczyk
6e084a67e4 refactor: ♻️ fulfillOrder to return void 2023-08-28 08:44:45 +02:00
Adrian Pilarczyk
f47481a74e feat: update schema version to 3.14
breaking
2023-08-28 08:44:15 +02:00
27 changed files with 6267 additions and 2264 deletions

View file

@ -0,0 +1,5 @@
fragment AvataxOrderMetadata on Order {
avataxEntityCode: metafield(key: "avataxEntityCode")
avataxTaxCalculationDate: metafield(key: "avataxTaxCalculationDate")
avataxDocumentCode: metafield(key: "avataxDocumentCode")
}

View file

@ -0,0 +1,4 @@
fragment Money on Money {
currency
amount
}

View file

@ -0,0 +1,22 @@
fragment OrderLine on OrderLine {
id
productSku
productName
quantity
taxClass {
id
}
unitPrice {
net {
amount
}
}
totalPrice {
net {
amount
}
tax {
amount
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,25 +1,3 @@
fragment OrderLine on OrderLine {
productSku
productName
quantity
taxClass {
id
}
unitPrice {
net {
amount
}
}
totalPrice {
net {
amount
}
tax {
amount
}
}
}
fragment OrderConfirmedSubscription on Order {
id
number

View file

@ -0,0 +1,43 @@
fragment OrderRefundedSubscription on Order {
id
avataxId: metafield(key: "avataxId")
...AvataxOrderMetadata
user {
id
email
}
channel {
id
slug
}
transactions {
id
refundedAmount {
...Money
}
}
shippingAddress {
...Address
}
}
fragment OrderRefundedEventSubscription on Event {
__typename
... on OrderRefunded {
order {
...OrderRefundedSubscription
}
recipient {
privateMetadata {
key
value
}
}
}
}
subscription OrderRefundedSubscription {
event {
...OrderRefundedEventSubscription
}
}

View file

@ -65,6 +65,6 @@
},
"private": true,
"saleor": {
"schemaVersion": "3.10"
"schemaVersion": "3.14"
}
}

View file

@ -53,6 +53,7 @@ export const defaultOrder: OrderConfirmedSubscriptionFragment = {
},
lines: [
{
id: "T3JkZXJMaW5lOjE=",
productSku: "328223580",
productName: "Monospace Tee",
quantity: 3,
@ -71,6 +72,7 @@ export const defaultOrder: OrderConfirmedSubscriptionFragment = {
},
},
{
id: "T3JkZXJMaW5lOjI=",
productSku: "328223581",
productName: "Monospace Tee",
quantity: 1,
@ -89,6 +91,7 @@ export const defaultOrder: OrderConfirmedSubscriptionFragment = {
},
},
{
id: "T3JkZXJMaW5lOjM=",
productSku: "118223581",
productName: "Paul's Balance 420",
quantity: 2,

View file

@ -55,6 +55,11 @@ export type VoidTransactionArgs = {
companyCode: string;
};
export type RefundTransactionParams = Pick<
CreateTransactionModel,
"customerCode" | "lines" | "date" | "addresses" | "code" | "companyCode"
>;
export class AvataxClient {
private client: Avatax;
@ -112,4 +117,17 @@ export class AvataxClient {
filter: `code eq ${useCode}`,
});
}
async refundTransaction(params: RefundTransactionParams) {
// https://developer.avalara.com/erp-integration-guide/refunds-badge/refunds-with-create-transactions/
return this.client.createOrAdjustTransaction({
model: {
createTransactionModel: {
type: DocumentType.ReturnInvoice,
commit: true,
...params,
},
},
});
}
}

View file

@ -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);
}

View file

@ -1,14 +1,16 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { OrderConfirmedSubscriptionFragment, TaxBaseFragment } 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";
import { OrderRefundedPayload } from "../../pages/api/webhooks/order-refunded";
import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
import { AvataxClient } from "./avatax-client";
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 { CalculateTaxesPayload } from "../../pages/api/webhooks/checkout-calculate-taxes";
import { AvataxOrderRefundedAdapter } from "./order-refunded/avatax-order-refunded-adapter";
export class AvataxWebhookService implements ProviderWebhookService {
config = defaultAvataxConfig;
@ -49,4 +51,10 @@ export class AvataxWebhookService implements ProviderWebhookService {
await adapter.send(payload);
}
async refundOrder(payload: OrderRefundedPayload) {
const adapter = new AvataxOrderRefundedAdapter(this.config);
return adapter.send(payload);
}
}

View file

@ -9,6 +9,7 @@ import { AvataxCalculateTaxesPayloadLinesTransformer } from "./avatax-calculate-
import { AvataxEntityTypeMatcher } from "../avatax-entity-type-matcher";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
import { CalculateTaxesPayload } from "../../../pages/api/webhooks/checkout-calculate-taxes";
import { AvataxAddressResolver } from "../order-confirmed/avatax-address-resolver";
export class AvataxCalculateTaxesPayloadTransformer {
private matchDocumentType(config: AvataxConfig): DocumentType {
@ -47,6 +48,11 @@ export class AvataxCalculateTaxesPayloadTransformer {
);
const customerCode = this.resolveCustomerCode(payload);
const addressResolver = new AvataxAddressResolver();
const addresses = addressResolver.resolve({
from: avataxConfig.address,
to: payload.taxBase.address!,
});
return {
model: {
@ -56,10 +62,7 @@ export class AvataxCalculateTaxesPayloadTransformer {
companyCode: avataxConfig.companyCode ?? defaultAvataxConfig.companyCode,
// * 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: avataxConfig.isAutocommit,
addresses: {
shipFrom: avataxAddressFactory.fromChannelAddress(avataxConfig.address),
shipTo: avataxAddressFactory.fromSaleorAddress(payload.taxBase.address!),
},
addresses,
currencyCode: payload.taxBase.currency,
lines: payloadLinesTransformer.transform(payload.taxBase, avataxConfig, matches),
date: new Date(),

View file

@ -0,0 +1,20 @@
import { AddressesModel } from "avatax/lib/models/AddressesModel";
import { AddressFragment } from "../../../../generated/graphql";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
import { avataxAddressFactory } from "../address-factory";
import { AvataxConfig } from "../avatax-connection-schema";
export class AvataxAddressResolver {
resolve({
from,
to,
}: {
from: AvataxConfig["address"];
to: AddressFragment | undefined | null;
}): AddressesModel {
return {
shipFrom: avataxAddressFactory.fromChannelAddress(from),
shipTo: avataxAddressFactory.fromSaleorAddress(taxProviderUtils.resolveOptionalOrThrow(to)),
};
}
}

View file

@ -1,7 +1,6 @@
import { DocumentType } from "avatax/lib/enums/DocumentType";
import { OrderConfirmedSubscriptionFragment } from "../../../../generated/graphql";
import { discountUtils } from "../../taxes/discount-utils";
import { avataxAddressFactory } from "../address-factory";
import { AvataxClient, CreateTransactionArgs } from "../avatax-client";
import { AvataxConfig, defaultAvataxConfig } from "../avatax-connection-schema";
import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-repository";
@ -10,6 +9,7 @@ import { AvataxEntityTypeMatcher } from "../avatax-entity-type-matcher";
import { AvataxDocumentCodeResolver } from "../avatax-document-code-resolver";
import { AvataxCalculationDateResolver } from "../avatax-calculation-date-resolver";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
import { AvataxAddressResolver } from "./avatax-address-resolver";
export const SHIPPING_ITEM_CODE = "Shipping";
@ -41,6 +41,12 @@ export class AvataxOrderConfirmedPayloadTransformer {
orderId: order.id,
});
const addressResolver = new AvataxAddressResolver();
const addresses = addressResolver.resolve({
from: avataxConfig.address,
to: order.shippingAddress!,
});
return {
model: {
code,
@ -50,11 +56,7 @@ export class AvataxOrderConfirmedPayloadTransformer {
companyCode: avataxConfig.companyCode ?? defaultAvataxConfig.companyCode,
// * 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: avataxConfig.isAutocommit,
addresses: {
shipFrom: avataxAddressFactory.fromChannelAddress(avataxConfig.address),
// billing or shipping address?
shipTo: avataxAddressFactory.fromSaleorAddress(order.billingAddress!),
},
addresses,
currencyCode: order.total.currency,
email: taxProviderUtils.resolveStringOrThrow(order.user?.email),
lines: linesTransformer.transform(order, avataxConfig, matches),

View file

@ -4,6 +4,7 @@ import { AvataxTaxCodeMatches } from "../tax-code/avatax-tax-code-match-reposito
import { AvataxOrderConfirmedTaxCodeMatcher } from "./avatax-order-confirmed-tax-code-matcher";
const mockedLine: OrderLineFragment = {
id: "T3JkZXJMaW5lOjE=",
productSku: "sku",
productName: "Test product",
quantity: 1,

View file

@ -0,0 +1,42 @@
import { Logger, createLogger } from "../../../lib/logger";
import { OrderRefundedPayload } from "../../../pages/api/webhooks/order-refunded";
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { AvataxClient } from "../avatax-client";
import { AvataxConfig } from "../avatax-connection-schema";
import { AvataxOrderRefundedPayloadTransformer } from "./avatax-order-refunded-payload-transformer";
export class AvataxOrderRefundedAdapter implements WebhookAdapter<OrderRefundedPayload, void> {
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);
this.logger.debug(
{
target,
},
`Refunding the transaction...`,
);
const response = await client.refundTransaction(target);
this.logger.debug({ response }, `Succesfully refunded the transaction`);
}
}

View file

@ -0,0 +1,18 @@
import { OrderRefundedPayload } from "../../../pages/api/webhooks/order-refunded";
import { RefundTransactionParams } from "../avatax-client";
export class AvataxOrderRefundedLinesTransformer {
transform(payload: OrderRefundedPayload): RefundTransactionParams["lines"] {
const refundTransactions =
payload.order?.transactions.filter((t) => t.refundedAmount.amount > 0) ?? [];
if (!refundTransactions.length) {
throw new Error("Cannot refund order without any refund transactions");
}
return refundTransactions.map((t) => ({
amount: -t.refundedAmount.amount,
taxIncluded: true,
}));
}
}

View file

@ -0,0 +1,47 @@
import { Logger, createLogger } from "../../../lib/logger";
import { OrderRefundedPayload } from "../../../pages/api/webhooks/order-refunded";
import { taxProviderUtils } from "../../taxes/tax-provider-utils";
import { RefundTransactionParams } from "../avatax-client";
import { AvataxConfig, defaultAvataxConfig } from "../avatax-connection-schema";
import { AvataxDocumentCodeResolver } from "../avatax-document-code-resolver";
import { AvataxAddressResolver } from "../order-confirmed/avatax-address-resolver";
import { AvataxOrderRefundedLinesTransformer } from "./avatax-order-refunded-lines-transformer";
export 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 addressResolver = new AvataxAddressResolver();
const linesTransformer = new AvataxOrderRefundedLinesTransformer();
const documentCodeResolver = new AvataxDocumentCodeResolver();
const addresses = addressResolver.resolve({
from: avataxConfig.address,
to: payload.order?.shippingAddress,
});
const lines = linesTransformer.transform(payload);
const customerCode = taxProviderUtils.resolveStringOrThrow(payload.order?.user?.id);
const code = documentCodeResolver.resolve({
avataxDocumentCode: payload.order?.avataxDocumentCode,
orderId: payload.order?.id,
});
return {
code,
lines,
customerCode,
addresses,
date: new Date(),
companyCode: avataxConfig.companyCode ?? defaultAvataxConfig.companyCode,
};
}
}

View file

@ -7,6 +7,7 @@ import {
import { Logger, createLogger } from "../../lib/logger";
import { OrderCancelledPayload } from "../../pages/api/webhooks/order-cancelled";
import { OrderRefundedPayload } from "../../pages/api/webhooks/order-refunded";
import { getAppConfig } from "../app/get-app-config";
import { AvataxWebhookService } from "../avatax/avatax-webhook.service";
import { ProviderConnection } from "../provider-connections/provider-connections";
@ -57,7 +58,11 @@ class ActiveTaxProviderService implements ProviderWebhookService {
}
async cancelOrder(payload: OrderCancelledPayload) {
this.client.cancelOrder(payload);
return this.client.cancelOrder(payload);
}
async refundOrder(payload: OrderRefundedPayload) {
return this.client.refundOrder(payload);
}
}

View file

@ -2,6 +2,7 @@ import { SyncWebhookResponsesMap } from "@saleor/app-sdk/handlers/next";
import { OrderConfirmedSubscriptionFragment } from "../../../generated/graphql";
import { CalculateTaxesPayload } from "../../pages/api/webhooks/checkout-calculate-taxes";
import { OrderCancelledPayload } from "../../pages/api/webhooks/order-cancelled";
import { OrderRefundedPayload } from "../../pages/api/webhooks/order-refunded";
export type CalculateTaxesResponse = SyncWebhookResponsesMap["ORDER_CALCULATE_TAXES"];
@ -11,4 +12,5 @@ export interface ProviderWebhookService {
calculateTaxes: (payload: CalculateTaxesPayload) => Promise<CalculateTaxesResponse>;
confirmOrder: (payload: OrderConfirmedSubscriptionFragment) => Promise<CreateOrderResponse>;
cancelOrder: (payload: OrderCancelledPayload) => Promise<void>;
refundOrder: (payload: OrderRefundedPayload) => Promise<void>;
}

View file

@ -8,7 +8,7 @@ function joinAddresses(address1: string, address2: string): string {
}
function mapSaleorAddressToTaxJarAddress(
address: SaleorAddress
address: SaleorAddress,
): Pick<TaxParams, "to_city" | "to_country" | "to_state" | "to_street" | "to_zip"> {
return {
to_street: joinAddresses(address.streetAddress1, address.streetAddress2),
@ -20,7 +20,7 @@ function mapSaleorAddressToTaxJarAddress(
}
function mapChannelAddressToTaxJarAddress(
address: TaxJarConfig["address"]
address: TaxJarConfig["address"],
): Pick<TaxParams, "from_city" | "from_country" | "from_state" | "from_street" | "from_zip"> {
return {
from_city: address.city,
@ -31,7 +31,7 @@ function mapChannelAddressToTaxJarAddress(
};
}
function mapChannelAddressToAddressParams(address: TaxJarConfig["address"]): AddressParams {
function mapChannelAddresstoParams(address: TaxJarConfig["address"]): AddressParams {
return {
city: address.city,
country: address.country,
@ -44,5 +44,5 @@ function mapChannelAddressToAddressParams(address: TaxJarConfig["address"]): Add
export const taxJarAddressFactory = {
fromSaleorToTax: mapSaleorAddressToTaxJarAddress,
fromChannelToTax: mapChannelAddressToTaxJarAddress,
fromChannelToParams: mapChannelAddressToAddressParams,
fromChannelToParams: mapChannelAddresstoParams,
};

View file

@ -9,19 +9,19 @@ export class TaxJarCalculateTaxesPayloadTransformer {
constructor(private readonly config: TaxJarConfig) {}
transform(taxBase: TaxBaseFragment, matches: TaxJarTaxCodeMatches): TaxJarCalculateTaxesTarget {
const fromAddress = taxJarAddressFactory.fromChannelToTax(this.config.address);
const from = taxJarAddressFactory.fromChannelToTax(this.config.address);
if (!taxBase.address) {
throw new Error("Customer address is required to calculate taxes in TaxJar.");
}
const lineTransformer = new TaxJarCalculateTaxesPayloadLinesTransformer();
const toAddress = taxJarAddressFactory.fromSaleorToTax(taxBase.address);
const to = taxJarAddressFactory.fromSaleorToTax(taxBase.address);
const taxParams: TaxJarCalculateTaxesTarget = {
params: {
...fromAddress,
...toAddress,
...from,
...to,
shipping: taxBase.shippingPrice.amount,
line_items: lineTransformer.transform(taxBase, matches),
},

View file

@ -8,6 +8,7 @@ const transformer = new TaxJarOrderConfirmedPayloadLinesTransformer();
const mockedLines: OrderConfirmedSubscriptionFragment["lines"] = [
{
id: "T3JkZXJMaW5lOjE=",
productSku: "sku",
productName: "Test product",
quantity: 1,
@ -29,6 +30,7 @@ const mockedLines: OrderConfirmedSubscriptionFragment["lines"] = [
},
},
{
id: "T3JkZXJMaW5lOjF=",
productSku: "sku-2",
productName: "Test product 2",
quantity: 2,

View file

@ -4,6 +4,7 @@ import { TaxJarOrderConfirmedTaxCodeMatcher } from "./taxjar-order-confirmed-tax
import { describe, expect, it } from "vitest";
const mockedLine: OrderLineFragment = {
id: "T3JkZXJMaW5lOjE=",
productSku: "sku",
productName: "Test product",
quantity: 1,

View file

@ -2,15 +2,15 @@ import { AuthData } from "@saleor/app-sdk/APL";
import {
OrderCancelledEventSubscriptionFragment,
OrderConfirmedSubscriptionFragment,
TaxBaseFragment,
} from "../../../generated/graphql";
import { Logger, createLogger } from "../../lib/logger";
import { CalculateTaxesPayload } from "../../pages/api/webhooks/checkout-calculate-taxes";
import { OrderRefundedPayload } from "../../pages/api/webhooks/order-refunded";
import { ProviderWebhookService } from "../taxes/tax-provider-webhook";
import { TaxJarCalculateTaxesAdapter } from "./calculate-taxes/taxjar-calculate-taxes-adapter";
import { TaxJarOrderConfirmedAdapter } from "./order-confirmed/taxjar-order-confirmed-adapter";
import { TaxJarClient } from "./taxjar-client";
import { TaxJarConfig } from "./taxjar-connection-schema";
import { CalculateTaxesPayload } from "../../pages/api/webhooks/checkout-calculate-taxes";
export class TaxJarWebhookService implements ProviderWebhookService {
client: TaxJarClient;
@ -47,6 +47,12 @@ export class TaxJarWebhookService implements ProviderWebhookService {
}
async cancelOrder(payload: OrderCancelledEventSubscriptionFragment) {
// TaxJar isn't implemented yet
// todo: implement
this.logger.debug("cancelOrder not implement for TaxJar");
}
async refundOrder(payload: OrderRefundedPayload) {
// todo: implement
this.logger.debug("refundOrder not implement for TaxJar");
}
}

View file

@ -7,6 +7,7 @@ import { orderCalculateTaxesSyncWebhook } from "./webhooks/order-calculate-taxes
import { orderConfirmedAsyncWebhook } from "./webhooks/order-confirmed";
import { REQUIRED_SALEOR_VERSION } from "../../../saleor-app";
import { orderCancelledAsyncWebhook } from "./webhooks/order-cancelled";
import { orderRefundedAsyncWebhook } from "./webhooks/order-refunded";
export default createManifestHandler({
async manifestFactory({ appBaseUrl }) {
@ -37,6 +38,7 @@ export default createManifestHandler({
checkoutCalculateTaxesSyncWebhook.getWebhookManifest(apiBaseURL),
orderConfirmedAsyncWebhook.getWebhookManifest(apiBaseURL),
orderCancelledAsyncWebhook.getWebhookManifest(apiBaseURL),
orderRefundedAsyncWebhook.getWebhookManifest(apiBaseURL),
],
};

View file

@ -0,0 +1,56 @@
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
OrderRefundedEventSubscriptionFragment,
UntypedOrderRefundedSubscriptionDocument,
} from "../../../../generated/graphql";
import { saleorApp } from "../../../../saleor-app";
import { createLogger } from "../../../lib/logger";
import { getActiveConnectionService } from "../../../modules/taxes/get-active-connection-service";
import { WebhookResponse } from "../../../modules/app/webhook-response";
export const config = {
api: {
bodyParser: false,
},
};
export type OrderRefundedPayload = Extract<
OrderRefundedEventSubscriptionFragment,
{ __typename: "OrderRefunded" }
>;
export const orderRefundedAsyncWebhook = new SaleorAsyncWebhook<OrderRefundedPayload>({
name: "OrderRefunded",
apl: saleorApp.apl,
event: "ORDER_REFUNDED",
query: UntypedOrderRefundedSubscriptionDocument,
webhookPath: "/api/webhooks/order-refunded",
});
export default orderRefundedAsyncWebhook.createHandler(async (req, res, ctx) => {
const logger = createLogger({ event: ctx.event });
const { payload } = ctx;
const webhookResponse = new WebhookResponse(res);
logger.info("Handler called with payload");
if (!payload.order) {
return webhookResponse.error(new Error("Insufficient order data"));
}
try {
const appMetadata = payload.recipient?.privateMetadata ?? [];
const channelSlug = payload.order.channel.slug;
const taxProvider = getActiveConnectionService(channelSlug, appMetadata, ctx.authData);
logger.info("Refunding order...");
await taxProvider.refundOrder(payload);
logger.info("Order refunded");
return webhookResponse.success();
} catch (error) {
return webhookResponse.error(error);
}
});