-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Description
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
- Create an order with items
- Apply a coupon code using
applyCouponCodemutation (e.g., "20SAVE" for 20% off) - Verify that
order.discountscontains the discount andorder.totalWithTaxis reduced - Call
removeCouponCodemutation with the same coupon code - Observe that
order.couponCodesis now empty, BUTorder.discountsstill 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.couponCodesshould not contain the removed codeorder.discountsshould be empty (or not contain discounts from removed coupon)orderLine.adjustmentsshould not containDISTRIBUTED_ORDER_PROMOTIONfor the removed coupon- Order totals (
subTotal,total,totalWithTax) should be recalculated correctly
Actual behavior
After calling removeCouponCode():
order.couponCodesis correctly emptyorder.discountsstill contains the discount from the removed couponorderLine.adjustmentsstill containsDISTRIBUTED_ORDER_PROMOTIONadjustments- 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:
removeCouponCoderemoves coupon fromorder.couponCodesapplyPriceAdjustmentsis called- 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
- 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:
- Remove coupon from
order.couponCodes - Immediately filter out adjustments for this specific coupon
- Save to DB with
{ reload: false } - Then call
applyPriceAdjustments - 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:
- Issue Discounts array in order entity is not cleared after removing the coupon code. #649 (similar issue fixed in v0.18.2, may have regressed)
Impact:
- Customers see incorrect prices after removing coupons
- Order totals are incorrect in the database
- Financial discrepancies in orders