Skip to content

Discounts remain on OrderLines after removing coupon code #4016

@monrostar

Description

@monrostar

Describe the bug

When calling removeCouponCode() mutation, the coupon code is correctly removed from order.couponCodes array, but the DISTRIBUTED_ORDER_PROMOTION adjustments remain on OrderLine.adjustments and order totals are not properly recalculated. This causes customers to see incorrect prices after removing a coupon.

To Reproduce

  1. Create an order with items
  2. Apply a coupon code using applyCouponCode mutation (e.g., "20SAVE" for 20% off)
  3. Verify that order.discounts contains the discount and order.totalWithTax is reduced
  4. Call removeCouponCode mutation with the same coupon code
  5. Observe that order.couponCodes is now empty, BUT order.discounts still contains the discount amount

Important: This bug is more likely to occur when blocking event handlers (e.g., custom plugins that call applyPriceAdjustments) are registered for order-related events.

Expected behavior

After calling removeCouponCode():

  • order.couponCodes should not contain the removed code
  • order.discounts should be empty (or not contain discounts from removed coupon)
  • orderLine.adjustments should not contain DISTRIBUTED_ORDER_PROMOTION for the removed coupon
  • Order totals (subTotal, total, totalWithTax) should be recalculated correctly

Actual behavior

After calling removeCouponCode():

  • order.couponCodes is correctly empty
  • order.discounts still contains the discount from the removed coupon
  • orderLine.adjustments still contains DISTRIBUTED_ORDER_PROMOTION adjustments
  • Order totals remain incorrect (discounted prices persist)

Example response after removeCouponCode:

{
  "couponCodes": [],
  "discounts": [
    {
      "type": "DISTRIBUTED_ORDER_PROMOTION",
      "description": "20SAVE",
      "amount": -5900,
      "amountWithTax": -5900
    }
  ]
}

Screenshots/Videos

N/A

Error logs

No errors are thrown - the bug is silent data corruption.

Environment (please complete the following information):

  • @vendure/core version: 3.5.1
  • Nodejs version: 20.x
  • Database: PostgreSQL
  • Operating System: Linux/macOS
  • Package manager: npm

Configuration

Standard Vendure configuration with promotions enabled.

Minimal reproduction

# 1. Add item to order
mutation {
  addItemToOrder(productVariantId: "1", quantity: 1) {
    ... on Order {
      id
      totalWithTax
    }
  }
}

# 2. Apply coupon
mutation {
  applyCouponCode(couponCode: "20SAVE") {
    ... on Order {
      couponCodes
      discounts { type description amount }
      totalWithTax
    }
  }
}

# 3. Remove coupon - BUG: discounts remain!
mutation {
  removeCouponCode(couponCode: "20SAVE") {
    ... on Order {
      couponCodes  # Empty - correct
      discounts { type description amount }  # Still has discount - BUG!
      totalWithTax  # Still discounted - BUG!
    }
  }
}

Workaround

Apply a patch to @vendure/core using patch-package:

--- a/node_modules/@vendure/core/dist/service/services/order.service.js
+++ b/node_modules/@vendure/core/dist/service/services/order.service.js
@@ -771,21 +775,34 @@ let OrderService = class OrderService {
     async removeCouponCode(ctx, orderId, couponCode) {
         const order = await this.getOrderOrThrow(ctx, orderId);
         if (order.couponCodes.includes(couponCode)) {
-            // When removing a couponCode which has triggered an Order-level discount
-            // we need to make sure we persist the changes to the adjustments array of
-            // any affected OrderLines.
-            const affectedOrderLines = order.lines.filter(line => line.adjustments.filter(a => a.type === generated_types_1.AdjustmentType.DISTRIBUTED_ORDER_PROMOTION)
-                .length);
             order.couponCodes = order.couponCodes.filter(cc => cc !== couponCode);
+            // CRITICAL: Immediately clear coupon adjustments and SAVE to DB
+            // This prevents blocking handlers from reloading stale adjustments
+            const couponCodeUpper = couponCode.toUpperCase();
+            const affectedLines = [];
+            for (const line of order.lines) {
+                if (line.adjustments && line.adjustments.length > 0) {
+                    const originalLength = line.adjustments.length;
+                    line.adjustments = line.adjustments.filter(adj =>
+                        !(adj.type === generated_types_1.AdjustmentType.DISTRIBUTED_ORDER_PROMOTION &&
+                          adj.description && adj.description.toUpperCase() === couponCodeUpper));
+                    if (line.adjustments.length !== originalLength) {
+                        affectedLines.push(line);
+                    }
+                }
+            }
+            // Save cleaned adjustments to DB BEFORE any event handlers can reload them
+            if (affectedLines.length > 0) {
+                await this.connection.getRepository(ctx, order_line_entity_1.OrderLine).save(affectedLines, { reload: false });
+            }
             await this.historyService.createHistoryEntryForOrder({
                 ctx,
                 orderId: order.id,
                 type: generated_types_1.HistoryEntryType.ORDER_COUPON_REMOVED,
                 data: { couponCode },
             });
-            await this.eventBus.publish(new coupon_code_event_1.CouponCodeEvent(ctx, couponCode, orderId, 'removed'));
+            // applyPriceAdjustments will recalculate and save all order lines with correct adjustments
             const result = await this.applyPriceAdjustments(ctx, order);
-            await this.connection.getRepository(ctx, order_line_entity_1.OrderLine).save(affectedOrderLines);
+            await this.eventBus.publish(new coupon_code_event_1.CouponCodeEvent(ctx, couponCode, orderId, 'removed'));
             return result;
         }
         else {

Additional context

Root Cause Analysis

The bug has two layers:

Layer 1: Original Vendure Bug (order of operations)

The original removeCouponCode method:

// 1. Captures lines with STALE adjustments BEFORE recalculation
const affectedOrderLines = order.lines.filter(line =>
    line.adjustments.filter(a => a.type === DISTRIBUTED_ORDER_PROMOTION).length);

// 2. applyPriceAdjustments clears adjustments and SAVES correct data
const result = await this.applyPriceAdjustments(ctx, order);

// 3. OVERWRITES correct data with stale adjustments!
await this.connection.getRepository(OrderLine).save(affectedOrderLines);

Problem: Step 3 saves the stale data captured in step 1, overwriting the correct data saved in step 2.

Layer 2: Blocking Event Handlers (deeper issue)

Even after removing save(affectedOrderLines), the bug persists if blocking event handlers reload order lines from the database before the correct data is saved.

Example scenario with a custom plugin:

  1. removeCouponCode removes coupon from order.couponCodes
  2. applyPriceAdjustments is called
  3. During applyPriceAdjustments, a blocking event handler (e.g., for surcharges) does:
    • entityHydrator.hydrate(ctx, order, { relations: ['lines'] }) - reloads lines from DB (with OLD adjustments!)
    • applyPriceAdjustments(ctx, order) - recalculates with stale data
  4. Stale adjustments are persisted

The Fix

The solution is to clear and save adjustments to DB immediately after removing the coupon code, BEFORE any other operations:

  1. Remove coupon from order.couponCodes
  2. Immediately filter out adjustments for this specific coupon
  3. Save to DB with { reload: false }
  4. Then call applyPriceAdjustments
  5. Publish CouponCodeEvent

This ensures that even if blocking event handlers reload order lines from the database, they get the already-cleaned data.

Key Differences: Original vs Fixed

Aspect Original Fixed
Save timing AFTER applyPriceAdjustments BEFORE applyPriceAdjustments
What is saved Stale data Cleaned data
Filter logic All lines with any promo Only adjustments for specific coupon
Resilience Breaks with blocking handlers Works with blocking handlers

Possibly related:

Impact:

  • Customers see incorrect prices after removing coupons
  • Order totals are incorrect in the database
  • Financial discrepancies in orders

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions