Skip to content

feat: Make cuopon code validation case insensitive#4419

Open
Ryrahul wants to merge 3 commits intovendurehq:masterfrom
Ryrahul:fix/cuopon-code-case-sensitive
Open

feat: Make cuopon code validation case insensitive#4419
Ryrahul wants to merge 3 commits intovendurehq:masterfrom
Ryrahul:fix/cuopon-code-case-sensitive

Conversation

@Ryrahul
Copy link
Copy Markdown
Contributor

@Ryrahul Ryrahul commented Feb 25, 2026

Description

Coupon codes are now validated case-insensitively, matching industry-standard behavior (Shopify, WooCommerce, Stripe, etc.).

Previously, a promotion with the coupon code Summer20 would reject summer20 and throw a CouponCodeInvalidError.
With this update, different casing variations of the same coupon code are treated as equivalent.


Changes

  • validateCouponCode() in PromotionService now uses LOWER() in the database query via TypeORM’s Raw() operator, and the redundant case-sensitive JS check (promotion.couponCode !== couponCode) has been removed.
  • applyCouponCode() now stores the canonical coupon code from the Promotion entity instead of the user-typed casing.
  • removeCouponCode(), Promotion.test(), and OrderModifier coupon comparisons have been updated to use case-insensitive matching.
  • Updated JSDoc on validateCouponCode() to document case-insensitive behavior.
  • Updated e2e test from "coupon code application is case-sensitive" to "coupon code application is case-insensitive".

Breaking Changes

None.

Existing coupon codes continue to work as before. Codes that previously failed due to casing differences will now succeed.


Checklist

📌 Always

  • I have set a clear title.
  • My PR is small and contains a single feature.
  • I have checked my own PR.

👍 Most of the time

  • I have added or updated test cases.

Fixes: #4364

@vercel
Copy link
Copy Markdown

vercel bot commented Feb 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vendure-storybook Ready Ready Preview, Comment Mar 10, 2026 0:49am

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

Coupon code handling was made case-insensitive across the codebase. Database queries now match coupon codes using a lowercased comparison. A new utility, couponCodesMatch, centralizes code comparisons. The order service and order-modifier logic canonicalize coupon codes on apply/remove and store the canonical code on the order; history entries and published events reference the canonical code. End-to-end tests were updated and added to cover applying lowercase codes, de-duplication, removal with different casing, and history assertions.

Suggested reviewers

  • michaelbromley
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title references 'cuopon' (misspelled) but accurately describes the main change: making coupon code validation case-insensitive, which is the primary objective.
Description check ✅ Passed The description comprehensively covers changes, breaking changes, related issue, and follows the template with all major sections completed and checklists marked.
Linked Issues check ✅ Passed The code changes fully address issue #4364 by implementing case-insensitive coupon code validation across all services, helpers, and tests as required.
Out of Scope Changes check ✅ Passed All code changes directly relate to case-insensitive coupon code validation per issue #4364; no unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/core/src/service/services/order.service.ts (1)

1032-1040: ⚠️ Potential issue | 🟡 Minor

Use stored canonical coupon code for removal history/event payloads.

Line 1032 removes case-insensitively, but Line 1038 and Line 1040 still emit the caller-provided casing. This can produce inconsistent audit/event data for the same coupon.

💡 Suggested fix
     async removeCouponCode(ctx: RequestContext, orderId: ID, couponCode: string) {
         const order = await this.getOrderOrThrow(ctx, orderId);
-        if (order.couponCodes.some(cc => cc.toLowerCase() === couponCode.toLowerCase())) {
-            order.couponCodes = order.couponCodes.filter(cc => cc.toLowerCase() !== couponCode.toLowerCase());
+        const matchedCouponCode = order.couponCodes.find(
+            cc => cc.toLowerCase() === couponCode.toLowerCase(),
+        );
+        if (matchedCouponCode) {
+            order.couponCodes = order.couponCodes.filter(
+                cc => cc.toLowerCase() !== matchedCouponCode.toLowerCase(),
+            );
             await this.historyService.createHistoryEntryForOrder({
                 ctx,
                 orderId: order.id,
                 type: HistoryEntryType.ORDER_COUPON_REMOVED,
-                data: { couponCode },
+                data: { couponCode: matchedCouponCode },
             });
-            await this.eventBus.publish(new CouponCodeEvent(ctx, couponCode, orderId, 'removed'));
+            await this.eventBus.publish(new CouponCodeEvent(ctx, matchedCouponCode, orderId, 'removed'));
             return this.applyPriceAdjustments(ctx, order);
         } else {
             return order;
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/service/services/order.service.ts` around lines 1032 -
1040, When removing a coupon, preserve and use the actual stored coupon string
(canonical casing) in history and event payloads instead of the caller-provided
couponCode; locate the matching stored value from order.couponCodes using a
case-insensitive comparison (e.g., find cc where cc.toLowerCase() ===
couponCode.toLowerCase()), assign it to a local variable (e.g., removedCode),
perform the filter removal using the same comparison, and pass removedCode into
historyService.createHistoryEntryForOrder and eventBus.publish (CouponCodeEvent)
so audit/event data uses the canonical stored coupon.
packages/core/src/service/helpers/order-modifier/order-modifier.ts (1)

586-607: ⚠️ Potential issue | 🟠 Major

Persist canonical coupon codes in modifyOrder instead of raw input casing.

Line 586 and Line 597 compare case-insensitively, but the block still stores input.couponCodes directly and writes apply history with user-typed casing. This can persist non-canonical duplicates (same code, different case) and inconsistent history data.

💡 Suggested fix
         if (input.couponCodes) {
+            const canonicalCouponCodes: string[] = [];
             for (const couponCode of input.couponCodes) {
                 const validationResult = await this.promotionService.validateCouponCode(
                     ctx,
                     couponCode,
                     order.customer && order.customer.id,
                 );
                 if (isGraphQlErrorResult(validationResult)) {
                     return validationResult as
                         | CouponCodeExpiredError
                         | CouponCodeInvalidError
                         | CouponCodeLimitError;
                 }
-                if (!order.couponCodes.some(cc => cc.toLowerCase() === couponCode.toLowerCase())) {
+                const canonicalCode = validationResult.couponCode;
+                if (!canonicalCouponCodes.some(cc => cc.toLowerCase() === canonicalCode.toLowerCase())) {
+                    canonicalCouponCodes.push(canonicalCode);
+                }
+                if (!order.couponCodes.some(cc => cc.toLowerCase() === canonicalCode.toLowerCase())) {
                     // This is a new coupon code that hadn't been applied before
                     await this.historyService.createHistoryEntryForOrder({
                         ctx,
                         orderId: order.id,
                         type: HistoryEntryType.ORDER_COUPON_APPLIED,
-                        data: { couponCode, promotionId: validationResult.id },
+                        data: { couponCode: canonicalCode, promotionId: validationResult.id },
                     });
                 }
             }
             for (const existingCouponCode of order.couponCodes) {
-                if (!input.couponCodes.some(cc => cc.toLowerCase() === existingCouponCode.toLowerCase())) {
+                if (
+                    !canonicalCouponCodes.some(
+                        cc => cc.toLowerCase() === existingCouponCode.toLowerCase(),
+                    )
+                ) {
                     // An existing coupon code has been removed
                     await this.historyService.createHistoryEntryForOrder({
                         ctx,
                         orderId: order.id,
                         type: HistoryEntryType.ORDER_COUPON_REMOVED,
                         data: { couponCode: existingCouponCode },
                     });
                 }
             }
-            order.couponCodes = input.couponCodes;
+            order.couponCodes = canonicalCouponCodes;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/service/helpers/order-modifier/order-modifier.ts` around
lines 586 - 607, In modifyOrder, normalize and deduplicate coupon codes before
persisting and logging so we don't store user-typed casing or duplicate
variants: for each validated couponCode use a canonical form (e.g.
couponCodeNormalized = couponCode.toLowerCase()) when calling
historyService.createHistoryEntryForOrder (instead of the raw couponCode) and
set order.couponCodes = normalizedDedupedArray (e.g.
input.couponCodes.map(c=>c.toLowerCase()).filter(unique)) so existing/new
comparisons still work; update references to validationResult.id in the
apply-history entries to use the normalized code where appropriate. Ensure you
update both the "applied" and "removed" history entry calls and the final
assignment to order.couponCodes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/core/src/service/helpers/order-modifier/order-modifier.ts`:
- Around line 586-607: In modifyOrder, normalize and deduplicate coupon codes
before persisting and logging so we don't store user-typed casing or duplicate
variants: for each validated couponCode use a canonical form (e.g.
couponCodeNormalized = couponCode.toLowerCase()) when calling
historyService.createHistoryEntryForOrder (instead of the raw couponCode) and
set order.couponCodes = normalizedDedupedArray (e.g.
input.couponCodes.map(c=>c.toLowerCase()).filter(unique)) so existing/new
comparisons still work; update references to validationResult.id in the
apply-history entries to use the normalized code where appropriate. Ensure you
update both the "applied" and "removed" history entry calls and the final
assignment to order.couponCodes.

In `@packages/core/src/service/services/order.service.ts`:
- Around line 1032-1040: When removing a coupon, preserve and use the actual
stored coupon string (canonical casing) in history and event payloads instead of
the caller-provided couponCode; locate the matching stored value from
order.couponCodes using a case-insensitive comparison (e.g., find cc where
cc.toLowerCase() === couponCode.toLowerCase()), assign it to a local variable
(e.g., removedCode), perform the filter removal using the same comparison, and
pass removedCode into historyService.createHistoryEntryForOrder and
eventBus.publish (CouponCodeEvent) so audit/event data uses the canonical stored
coupon.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 738473d and 5fbf26b.

📒 Files selected for processing (5)
  • packages/core/e2e/order-promotion.e2e-spec.ts
  • packages/core/src/entity/promotion/promotion.entity.ts
  • packages/core/src/service/helpers/order-modifier/order-modifier.ts
  • packages/core/src/service/services/order.service.ts
  • packages/core/src/service/services/promotion.service.ts

Copy link
Copy Markdown
Collaborator

@grolmus grolmus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

The canonical code approach is the right architecture — storing the promotion's actual coupon code rather than the user-typed casing avoids data inconsistency. This is clearly the better approach compared to #4438. However, there are a few things that need addressing before merge.

Must Fix

Extract a couponCodesMatch() helper function

There are 7 separate locations in this diff with the pattern cc.toLowerCase() === something.toLowerCase(). This is a maintenance problem — if the normalization logic ever needs to change (e.g., trimming whitespace, using toLocaleLowerCase()), every instance needs to be found and updated. Extract a simple helper:

function couponCodesMatch(a: string, b: string): boolean {
    return a.toLowerCase() === b.toLowerCase();
}

Place it somewhere accessible to the services that need it (e.g., packages/core/src/service/helpers/utils/).

Should Fix

1. Missing test for case-insensitive removal

There's no test that applies a coupon with one casing and removes it with a different casing. For example: apply TESTCOUPON, then remove with testcoupon. The code handles this correctly (via matchedCode in removeCouponCode), but it needs test coverage.

2. Missing test for case-insensitive de-duplication

There's no test that verifies applying the same coupon code with different casings doesn't create duplicates. For example: apply testcoupon, then apply TESTCOUPON — assert the order still only has one coupon code and no duplicate history entry is created.

Nitpicks (non-blocking)

  • The Raw() TypeORM operator is new to this codebase (no existing usages). A brief comment above the usage explaining why it's needed (case-insensitive DB lookup) would help the next developer who encounters it.
  • The useful comment // This is a new coupon code that hadn't been applied before was removed in order-modifier.ts — consider keeping it, as it explains the "why" of the check.

Notes

  • Raw() with parameterized :couponCode binding is safe — no SQL injection risk.
  • LOWER() is supported across all three Vendure DB backends (MariaDB, PostgreSQL, SQLite).
  • The canonicalCouponCodes dedup logic in order-modifier.ts correctly handles the edge case where input.couponCodes contains the same code with different casings.

@Ryrahul
Copy link
Copy Markdown
Contributor Author

Ryrahul commented Mar 10, 2026

Review Summary

The canonical code approach is the right architecture — storing the promotion's actual coupon code rather than the user-typed casing avoids data inconsistency. This is clearly the better approach compared to #4438. However, there are a few things that need addressing before merge.

Must Fix

Extract a couponCodesMatch() helper function

There are 7 separate locations in this diff with the pattern cc.toLowerCase() === something.toLowerCase(). This is a maintenance problem — if the normalization logic ever needs to change (e.g., trimming whitespace, using toLocaleLowerCase()), every instance needs to be found and updated. Extract a simple helper:

function couponCodesMatch(a: string, b: string): boolean {
    return a.toLowerCase() === b.toLowerCase();
}

Place it somewhere accessible to the services that need it (e.g., packages/core/src/service/helpers/utils/).

Should Fix

1. Missing test for case-insensitive removal

There's no test that applies a coupon with one casing and removes it with a different casing. For example: apply TESTCOUPON, then remove with testcoupon. The code handles this correctly (via matchedCode in removeCouponCode), but it needs test coverage.

2. Missing test for case-insensitive de-duplication

There's no test that verifies applying the same coupon code with different casings doesn't create duplicates. For example: apply testcoupon, then apply TESTCOUPON — assert the order still only has one coupon code and no duplicate history entry is created.

Nitpicks (non-blocking)

  • The Raw() TypeORM operator is new to this codebase (no existing usages). A brief comment above the usage explaining why it's needed (case-insensitive DB lookup) would help the next developer who encounters it.
  • The useful comment // This is a new coupon code that hadn't been applied before was removed in order-modifier.ts — consider keeping it, as it explains the "why" of the check.

Notes

  • Raw() with parameterized :couponCode binding is safe — no SQL injection risk.
  • LOWER() is supported across all three Vendure DB backends (MariaDB, PostgreSQL, SQLite).
  • The canonicalCouponCodes dedup logic in order-modifier.ts correctly handles the edge case where input.couponCodes contains the same code with different casings.

Thanks for the review and feedback, will address the issues

  • The useful comment // This is a new coupon code that hadn't been applied before was removed in order-modifier.ts — consider keeping it, as it explains the "why" of the check. Ig this comment is still present and has not been removed

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/core/src/service/helpers/utils/coupon-codes-match.ts (1)

8-10: Consider adding null/undefined guard for defensive programming.

The function will throw a TypeError if either argument is null or undefined. While current call sites appear to validate inputs beforehand, adding a defensive check would make the helper more robust for future usage.

🛡️ Optional: Add null guard
 export function couponCodesMatch(a: string, b: string): boolean {
+    if (!a || !b) {
+        return false;
+    }
     return a.toLowerCase() === b.toLowerCase();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/service/helpers/utils/coupon-codes-match.ts` around lines 8
- 10, The couponCodesMatch helper can throw if inputs are null/undefined; update
couponCodesMatch(a: string, b: string) to first guard for null/undefined (e.g.
if (a == null || b == null) return false) and only then perform the
case-insensitive comparison with a.toLowerCase() === b.toLowerCase(); reference
the function couponCodesMatch to locate and update the guard so empty-string
behavior remains unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/core/src/service/helpers/utils/coupon-codes-match.ts`:
- Around line 8-10: The couponCodesMatch helper can throw if inputs are
null/undefined; update couponCodesMatch(a: string, b: string) to first guard for
null/undefined (e.g. if (a == null || b == null) return false) and only then
perform the case-insensitive comparison with a.toLowerCase() ===
b.toLowerCase(); reference the function couponCodesMatch to locate and update
the guard so empty-string behavior remains unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 78cb29bb-8f18-4c05-9594-190ee5125de0

📥 Commits

Reviewing files that changed from the base of the PR and between 9826a5e and 0064d97.

📒 Files selected for processing (6)
  • packages/core/e2e/order-promotion.e2e-spec.ts
  • packages/core/src/entity/promotion/promotion.entity.ts
  • packages/core/src/service/helpers/order-modifier/order-modifier.ts
  • packages/core/src/service/helpers/utils/coupon-codes-match.ts
  • packages/core/src/service/services/order.service.ts
  • packages/core/src/service/services/promotion.service.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/core/src/service/services/promotion.service.ts
  • packages/core/src/service/services/order.service.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Coupon codes are case-sensitive in backend but Admin UI doesn’t enforce/indicate casing

2 participants