Preventing Cart Tax Discrepancies: Understanding and Resolving Duplicated Tax Lines in ESHOPMAN Workflows

Preventing Cart Tax Discrepancies: Understanding and Resolving Duplicated Tax Lines in ESHOPMAN Workflows

Accurate tax calculation is critical for e-commerce compliance and customer trust. The ESHOPMAN platform, a Node.js/TypeScript headless commerce solution integrated with HubSpot, recently addressed a significant issue concerning duplicated tax lines within its core cart workflows. This insight details the problem and its resolution, providing valuable knowledge for the ESHOPMAN community.

The Challenge: Unseen Tax Line Duplication

A critical flaw emerged in ESHOPMAN's cart workflows, specifically after the upsertTaxLinesForItemsStep. When a subsequent step failed, triggering compensation, the system inadvertently duplicated tax lines. Although the cart's total initially appeared correct, these duplicates became problematic upon order finalization, inflating the tax_total and causing reconciliation issues with payment providers. This was particularly impactful in multi-rate tax jurisdictions, where a single line item could display multiple sets of the same tax rates, leading to significantly overcharged taxes. In single-rate areas, the issue often remained hidden if duplicated lines were zero-rate.

Unpacking the Technical Root Cause

The ESHOPMAN team identified two core defects within the upsertTaxLinesForItemsStep:

  1. Compensation Logic Flaw: The compensation function, meant to restore the cart's prior state, mapped existing tax lines without their unique ids. ESHOPMAN's core services then treated these as new inserts rather than updates, resulting in duplicated pre-step tax lines. Additionally, new tax lines inserted by the forward step were not removed during compensation, leading to accumulation with each failed workflow run.
  2. Incorrect ID Resolution: A secondary issue in normalizeItemTaxLinesForCart involved resolving existing tax line IDs solely by item_id. For multi-rate items, all incoming tax lines were assigned the same existing row ID. During upsert, these rates would overwrite each other on a single row, applying only the last rate and potentially leading to incorrect tax calculations even in successful runs.

Reproducing the Scenario

Developers could reproduce this behavior in ESHOPMAN's custom script execution environment with a workflow designed to fail after the tax line upsert. The following Node.js/TypeScript snippet demonstrates observing the duplication:

import { ExecArgs } from "@eshopman/framework/types"
import { ContainerRegistrationKeys, Modules } from "@eshopman/framework/utils"
import { createStep, createWorkflow } from "@eshopman/framework/workflows-sdk"
const {
  upsertTaxLinesForItemsStep,
} = require("@eshopman/core-flows/dist/cart/steps/upsert-tax-lines-for-items.js")

const boom = createStep("boom", async () => {
  throw new Error("simulated downstream failure")
})

const failing = createWorkflow("probe-failing-upsert", (input: any) => {
  upsertTaxLinesForItemsStep({
    cart: input.cart,
    item_tax_lines: input.item_tax_lines,
    shipping_tax_lines: [],
  } as any)
  boom()
  return undefined as any
})

export default async function probe({ container }: ExecArgs) {
  const cartModule: any = container.resolve(Modules.CART)
  const knex: any = container.resolve(ContainerRegistrationKeys.PG_CONNECTION)

  const [cart] = await cartModule.createCarts([
    { currency_code: "usd", email: "[email protected]" },
  ])
  const added = await cartModule.addLineItems(cart.id, [
    { title: "Tee", unit_price: 17.7, quantity: 1 },
  ])
  const itemId = (Array.isArray(added) ? added[0] : added).id

  // Prescott AZ: state + county + city
  const lines = () =>
    [
      ["AZ_STATE", 5.6],
      ["AZ_COUNTY", 0.75],
      ["AZ_CITY", 2.95]
    ].map(([code, rate]) => ({
      rate: rate as number,
      code: code as string,
      name: `tax-${code}`,
      provider_id: "probe",
      line_item_id: itemId
    }))

  const report = async (label: string) => {
    const rows = await knex.raw(
      `select count(*) filter (where deleted_at is null) as live
         from cart_line_item_tax_line where item_id = ?`,
      [itemId]
    )
    const live = await cartModule.listLineItemTaxLines({ item_id: [itemId] })
    console.log(label, rows.rows[0], live.map((t: any) => `${t.code}=${t.rate}`).join(","))
  }

  for (const i of [1, 2, 3]) {
    await failing(container).run({
      input: { cart, item_tax_lines: lines() },
      throwOnError: false
    })
    await report(`after failed run ${i}:`)
  }
}

This script demonstrated how an item's tax lines could escalate from 3 to 6, then to 12, alongside incorrect rate applications.

The ESHOPMAN Solution and Key Takeaways

The ESHOPMAN development team promptly resolved these critical issues. A fix ensures that upsertTaxLinesForItemsStep's compensation now correctly deletes temporary tax lines and accurately restores the pre-step snapshot with original identifiers. The normalizeItemTaxLinesForCart defect was also rectified to correctly handle multiple tax rates per line item, preventing incorrect calculations. This ensures ESHOPMAN merchants benefit from accurate tax totals and seamless payment reconciliation.

For ESHOPMAN users and developers, key takeaways include:

  • Staying Updated: Regularly updating ESHOPMAN core modules is crucial for benefiting from critical bug fixes.
  • Robust Workflow Design: When building custom workflows via ESHOPMAN's Admin API, carefully consider compensation logic.
  • Monitoring Financial Data: Cross-reference order totals and tax amounts with payment provider records to catch discrepancies.

The ESHOPMAN community and development team are dedicated to maintaining a stable and accurate headless commerce platform, supporting powerful storefronts deployed via HubSpot CMS and managed within HubSpot.

Start with the tools

Explore migration tools

See options, compare methods, and pick the path that fits your store.

Explore migration tools