medusajs/medusa

[Bug]: Gift card line items are taxed when cart taxes are recalculated (updateTaxLinesWorkflow drops is_giftcard)

Open

Aperta il 31 mag 2026

Vedi su GitHub
 (1 commento) (0 reazioni) (0 assegnatari)TypeScript (22.539 star) (2090 fork)batch import
good first issuetype: bug

Descrizione

What

A line item flagged is_giftcard: true is charged tax in a cart whenever taxes are recalculated through updateTaxLinesWorkflow (the force_refresh: true path of refreshCartItemsWorkflow, hit on price refresh, promotion-code changes, and similar). A gift card is prepaid stored value, not a taxable supply. VAT is due at redemption on the real goods, never on the sale of the card, so no tax line should be attached to the gift-card item.

Example for a €420 gift card in a region with a 19% rate (automatic_taxes = true):

Subtotal (excl. tax)  €352.94
Taxes                  €67.06     (should be €0.00)
Total                 €420.00

At the data layer the cart line item has is_giftcard = true and a cart_line_item_tax_line row at rate 19. Forcing a recalc does not clear it, it adds another 19% line, so the bug fires on every tax computation that goes through this path (it is not stale data).

Medusa already intends to skip tax on gift cards. getItemTaxLinesStep filters them out before calling the tax provider:

// packages/core/core-flows/src/tax/steps/get-item-tax-lines.ts:179
const filteredItems = items.filter(
  (item) => !item.is_giftcard || !isDefined(item.is_giftcard)
)

The filter itself is correct. The problem is that on this path the items it receives never carry the is_giftcard flag, so !isDefined(undefined) is true, the gift-card item is kept, and it is sent to the tax provider.

Expected behavior

A line item with is_giftcard: true produces no tax line, and its contribution to the cart's tax_total is zero, regardless of which tax-recalculation path runs.

Actual behavior

When taxes are recalculated via updateTaxLinesWorkflow, the gift-card line gets a cart_line_item_tax_line at the region rate and tax_total is non-zero.

Root cause

getItemTaxLinesStep has three callers. Two pass items that carry is_giftcard; one does not:

Caller items source Selects is_giftcard?
cart/workflows/upsert-tax-lines.ts:154 input.items (results of createLineItemsStep / updateLineItemsStep, which include the persisted is_giftcard column) yes
order/workflows/update-tax-lines.ts:234 fetched order items; fields include items.is_giftcard (line 27) yes
cart/workflows/update-tax-lines.ts:159 cart.items, fetched with a cartFields array (lines 19-67) that selects neither items.is_giftcard nor items.product.is_giftcard no

Because cart/workflows/update-tax-lines.ts fetches the cart without the gift-card flag, cart.items[].is_giftcard is undefined at filter time, and the gift card is taxed. This workflow is invoked by the force_refresh: true branch of refreshCartItemsWorkflow (refresh-cart-items.ts:202-208).

For comparison, the order workflow selects items.is_giftcard, and cartFieldsForRefreshSteps (cart/utils/fields.ts) selects both items.* and items.product.is_giftcard. So the intended contract is that the flag must be selected onto the items handed to the filter. cart/workflows/update-tax-lines.ts is the only tax workflow missing it.

The line-item column is set correctly at add time (prepare-line-item-data.ts:166: is_giftcard: variant?.product?.is_giftcard ?? false) and is true in the database. This path simply never selects that column when fetching the cart for tax.

Steps to reproduce

  1. A region with automatic_taxes = true and a default tax rate (for example DE 19%).
  2. A product with is_giftcard: true and a price.
  3. Add it to a cart in that region.
  4. Trigger a tax recalculation that goes through the force_refresh path (for example apply or change a promotion code, or refresh prices).
  5. Observe the gift-card line now has a cart_line_item_tax_line row at the region rate and the cart tax_total is non-zero. Expected: zero.

No plugin is required, is_giftcard is core. The official loyalty plugin makes such products easy to create and its carts routinely hit the refresh path, which is how this surfaces in practice.

Suggested fix

Add the gift-card flag to the cartFields array in packages/core/core-flows/src/cart/workflows/update-tax-lines.ts so the fetched cart items carry it into the filter, matching the order workflow and cartFieldsForRefreshSteps:

   "items.unit_price",
+  "items.is_giftcard",
   "items.tax_lines.id",

items.is_giftcard (the line item's own column) is sufficient and matches how order/workflows/update-tax-lines.ts does it. Verified end to end: with this field added, the gift-card line shows €0.00 tax after recalculation.

Environment

  • @medusajs/medusa / @medusajs/core-flows 2.15.5 (identical code in 2.15.3)
  • Node.js v22.22.2
  • PostgreSQL 16
  • Reproducible via the cart refresh flow and the Store API cart taxes route

Guida contributor