feat: ✨ add createOrAdjustTransaction refund flow
This commit is contained in:
parent
2f8051bacc
commit
c302f41f3e
15 changed files with 140 additions and 184 deletions
22
apps/taxes/graphql/fragments/OrderLine.graphql
Normal file
22
apps/taxes/graphql/fragments/OrderLine.graphql
Normal file
|
@ -0,0 +1,22 @@
|
|||
fragment OrderLine on OrderLine {
|
||||
id
|
||||
productSku
|
||||
productName
|
||||
quantity
|
||||
taxClass {
|
||||
id
|
||||
}
|
||||
unitPrice {
|
||||
net {
|
||||
amount
|
||||
}
|
||||
}
|
||||
totalPrice {
|
||||
net {
|
||||
amount
|
||||
}
|
||||
tax {
|
||||
amount
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -1,40 +1,23 @@
|
|||
fragment Payment on Payment {
|
||||
transactions {
|
||||
kind
|
||||
amount {
|
||||
...Money
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment OrderRefundedSubscription on Order {
|
||||
id
|
||||
avataxId: metafield(key: "avataxId")
|
||||
...AvataxOrderMetadata
|
||||
user {
|
||||
id
|
||||
email
|
||||
}
|
||||
channel {
|
||||
id
|
||||
slug
|
||||
}
|
||||
lines {
|
||||
productSku
|
||||
}
|
||||
totalRefunded {
|
||||
transactions {
|
||||
id
|
||||
refundedAmount {
|
||||
...Money
|
||||
}
|
||||
totalGrantedRefund {
|
||||
...Money
|
||||
}
|
||||
totalRefundPending {
|
||||
...Money
|
||||
}
|
||||
grantedRefunds {
|
||||
amount {
|
||||
...Money
|
||||
}
|
||||
reason
|
||||
}
|
||||
payments {
|
||||
...Payment
|
||||
shippingAddress {
|
||||
...Address
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -55,7 +55,10 @@ export type VoidTransactionArgs = {
|
|||
companyCode: string;
|
||||
};
|
||||
|
||||
export type RefundTransactionParams = Parameters<Avatax["refundTransaction"]>[0];
|
||||
export type RefundTransactionParams = Pick<
|
||||
CreateTransactionModel,
|
||||
"customerCode" | "lines" | "date" | "addresses" | "code" | "companyCode"
|
||||
>;
|
||||
|
||||
export class AvataxClient {
|
||||
private client: Avatax;
|
||||
|
@ -116,6 +119,15 @@ export class AvataxClient {
|
|||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
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";
|
||||
import { CreateTransactionModel } from "avatax/lib/models/CreateTransactionModel";
|
||||
|
||||
export class AvataxAddressResolver {
|
||||
resolve({
|
||||
|
@ -9,11 +10,11 @@ export class AvataxAddressResolver {
|
|||
to,
|
||||
}: {
|
||||
from: AvataxConfig["address"];
|
||||
to: AddressFragment;
|
||||
}): CreateTransactionModel["addresses"] {
|
||||
to: AddressFragment | undefined | null;
|
||||
}): AddressesModel {
|
||||
return {
|
||||
shipFrom: avataxAddressFactory.fromChannelAddress(from),
|
||||
shipTo: avataxAddressFactory.fromSaleorAddress(to),
|
||||
shipTo: avataxAddressFactory.fromSaleorAddress(taxProviderUtils.resolveOptionalOrThrow(to)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,60 +1,9 @@
|
|||
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<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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
@ -79,11 +28,15 @@ export class AvataxOrderRefundedAdapter implements WebhookAdapter<OrderRefundedP
|
|||
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 of id: ${target.transactionCode}`,
|
||||
);
|
||||
this.logger.debug({ response }, `Succesfully refunded the transaction`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue