feat: add createOrAdjustTransaction refund flow

This commit is contained in:
Adrian Pilarczyk 2023-08-30 12:20:55 +02:00
parent 2f8051bacc
commit c302f41f3e
15 changed files with 140 additions and 184 deletions

View file

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

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 { fragment OrderConfirmedSubscription on Order {
id id
number number

View file

@ -1,40 +1,23 @@
fragment Payment on Payment {
transactions {
kind
amount {
...Money
}
}
}
fragment OrderRefundedSubscription on Order { fragment OrderRefundedSubscription on Order {
id id
avataxId: metafield(key: "avataxId") avataxId: metafield(key: "avataxId")
...AvataxOrderMetadata ...AvataxOrderMetadata
user {
id
email
}
channel { channel {
id id
slug slug
} }
lines { transactions {
productSku id
} refundedAmount {
totalRefunded {
...Money ...Money
} }
totalGrantedRefund {
...Money
} }
totalRefundPending { shippingAddress {
...Money ...Address
}
grantedRefunds {
amount {
...Money
}
reason
}
payments {
...Payment
} }
} }

View file

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

View file

@ -55,7 +55,10 @@ export type VoidTransactionArgs = {
companyCode: string; companyCode: string;
}; };
export type RefundTransactionParams = Parameters<Avatax["refundTransaction"]>[0]; export type RefundTransactionParams = Pick<
CreateTransactionModel,
"customerCode" | "lines" | "date" | "addresses" | "code" | "companyCode"
>;
export class AvataxClient { export class AvataxClient {
private client: Avatax; private client: Avatax;
@ -116,6 +119,15 @@ export class AvataxClient {
} }
async refundTransaction(params: RefundTransactionParams) { async refundTransaction(params: RefundTransactionParams) {
return this.client.refundTransaction(params); // 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

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

View file

@ -1,60 +1,9 @@
import { RefundType } from "avatax/lib/enums/RefundType";
import { z } from "zod";
import { Logger, createLogger } from "../../../lib/logger"; import { Logger, createLogger } from "../../../lib/logger";
import { OrderRefundedPayload } from "../../../pages/api/webhooks/order-refunded"; import { OrderRefundedPayload } from "../../../pages/api/webhooks/order-refunded";
import { WebhookAdapter } from "../../taxes/tax-webhook-adapter"; import { WebhookAdapter } from "../../taxes/tax-webhook-adapter";
import { AvataxClient, RefundTransactionParams } from "../avatax-client"; import { AvataxClient } from "../avatax-client";
import { AvataxConfig, defaultAvataxConfig } from "../avatax-connection-schema"; import { AvataxConfig } from "../avatax-connection-schema";
import { taxProviderUtils } from "../../taxes/tax-provider-utils"; import { AvataxOrderRefundedPayloadTransformer } from "./avatax-order-refunded-payload-transformer";
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<RefundTransactionParams, "transactionCode" | "companyCode"> = {
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<OrderRefundedPayload, void> { export class AvataxOrderRefundedAdapter implements WebhookAdapter<OrderRefundedPayload, void> {
private logger: Logger; private logger: Logger;
@ -79,11 +28,15 @@ export class AvataxOrderRefundedAdapter implements WebhookAdapter<OrderRefundedP
const payloadTransformer = new AvataxOrderRefundedPayloadTransformer(); const payloadTransformer = new AvataxOrderRefundedPayloadTransformer();
const target = payloadTransformer.transform(payload, this.config); const target = payloadTransformer.transform(payload, this.config);
this.logger.debug(
{
target,
},
`Refunding the transaction...`,
);
const response = await client.refundTransaction(target); const response = await client.refundTransaction(target);
this.logger.debug( this.logger.debug({ response }, `Succesfully refunded the transaction`);
{ response },
`Succesfully refunded the transaction of id: ${target.transactionCode}`,
);
} }
} }

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

@ -1,53 +0,0 @@
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",
},
]);
});
});

View file

@ -1,11 +0,0 @@
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);
}
}

View file

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

View file

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

View file

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

View file

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