Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ef16f24
feat: add promotional offer checks and enhance discount editing logic
Jul 19, 2026
7601797
feat: enhance coupon functionality with scope and exclusions
Jul 21, 2026
a9f58bd
Merge branch 'add_brand_and_collection_exclusions_to_pos_coupon' into…
Jul 21, 2026
3845f1b
fix: add frappe-dependencies to pyproject.toml for Frappe Cloud
Jul 21, 2026
af8b8dd
feat: enhance coupon and promotion handling with item-level discount …
Jul 21, 2026
096774f
Merge branch 'enhance_coupon_and_promotion_handling_with_item-level_d…
Jul 21, 2026
f502d6b
refactor: remove unused coupon discount calculation function from POS…
Jul 21, 2026
f89e671
fix: correct conditional rendering for item-level promotion display i…
Jul 21, 2026
52ab0dd
Merge branch 'enhance_coupon_and_promotion_handling_with_item-level_d…
Jul 21, 2026
1ae471e
feat: add validation for unique promotion types per item in promotion…
Jul 23, 2026
b7c9bbd
feat: enhance coupon validation and promotion interaction matrix
Jul 23, 2026
a9adea0
feat: make mobile number mandatory in CreateCustomerDialog
MostafaKadry Jul 22, 2026
1b4dc57
feat: implement cummulative disccount
MostafaKadry Jul 27, 2026
f5311ec
Merge pull request #337 from BrainWise-DEV/PN-79-accumulative-discount
MostafaKadry Jul 27, 2026
23fcefb
feat: implement GWP promotion handling and related enhancements
Jul 27, 2026
4caef20
feat: refactor GWP promotion logic in CustomPricingRule
Jul 28, 2026
6837abc
feat: enhance offer discount management in POS cart
Jul 28, 2026
92e2885
feat: implement accumulative discount mode with automated slab manage…
MostafaKadry Jul 28, 2026
8ae2ce8
test: add regression test suite for promotion precedence and scoping
MostafaKadry Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 60 additions & 59 deletions POS/src/components/sale/CouponDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -232,13 +232,11 @@

<script setup>
import { DEFAULT_CURRENCY, formatCurrency as formatCurrencyUtil } from "@/utils/currency";
import { call } from "@/utils/apiWrapper";
import { Button, Dialog, Input, createResource } from "frappe-ui";
import { ref, watch } from "vue";
import { useInvoice } from "@/composables/useInvoice";
import { useToast } from "@/composables/useToast";

// Get calculateDiscountAmount helper from composable
const { calculateDiscountAmount } = useInvoice();
const { showSuccess, showError, showWarning } = useToast();

const props = defineProps({
Expand Down Expand Up @@ -294,18 +292,40 @@ const giftCardsResource = createResource({
},
});

// Resource to validate coupon
const couponResource = createResource({
url: "pos_next.api.offers.validate_coupon",
makeParams() {
return {
coupon_code: couponCode.value,
customer: props.customer,
company: props.company,
};
},
auto: false,
});
// Resource to validate coupon — use call() in applyCoupon for reliable response parsing

function unwrapCouponValidation(raw) {
if (!raw) return null;
if (raw.valid !== undefined) return raw;
if (raw.message?.valid !== undefined) return raw.message;
if (typeof raw.message === "object" && raw.message) return raw.message;
if (typeof raw.message === "string") return { valid: false, message: raw.message };
return raw;
}

function buildCartItemsSnapshot() {
const items = props.items || [];
return items.map((item, index) => ({
item_code: item.item_code,
item_name: item.item_name,
brand: item.brand || "",
item_group: item.item_group || "",
qty: item.quantity || item.qty || 0,
quantity: item.quantity || item.qty || 0,
rate: item.rate || 0,
price_list_rate: item.price_list_rate || item.rate || 0,
amount: item.amount || 0,
discount_percentage: item.discount_percentage || 0,
discount_amount: item.discount_amount || 0,
pricing_rules: item.pricing_rules || null,
is_free_item: item.is_free_item || 0,
is_already_discounted: item.is_already_discounted || 0,
discount_source: item.discount_source || "",
coupon_code: item.coupon_code || "",
idx: index + 1,
name: item.name || null,
}));
}

watch(
() => props.modelValue,
Expand Down Expand Up @@ -347,79 +367,60 @@ function applyGiftCard(card) {
applyCoupon();
}

function getCouponBaseAmount(coupon) {
const grandTotal = Number.parseFloat(props.grandTotal || 0);
const taxAmount = Number.parseFloat(props.taxAmount || 0);
const netTotal = Math.max(grandTotal - taxAmount, 0);

return coupon.apply_on === "Grand Total" ? grandTotal : netTotal;
}

async function applyCoupon() {
if (!couponCode.value.trim()) {
errorMessage.value = __("Please enter a coupon code");
return;
}

if (!props.items?.length) {
errorMessage.value = __("Add items to the cart before applying a coupon");
showWarning(errorMessage.value);
return;
}

applying.value = true;
errorMessage.value = "";

try {
await couponResource.reload();
// Frappe wraps response in { message: {...} }
const result = couponResource.data?.message || couponResource.data;

// Handle if result is the actual response object
const validationData =
typeof result === "object" && result.valid !== undefined
? result
: couponResource.data;
const result = await call("pos_next.api.offers.validate_coupon", {
coupon_code: couponCode.value,
customer: props.customer,
company: props.company,
items: buildCartItemsSnapshot(),
});
const validationData = unwrapCouponValidation(result);

if (!validationData || !validationData.valid) {
if (!validationData || validationData.valid !== true) {
errorMessage.value =
validationData?.message || __("The coupon code you entered is not valid");
showError(errorMessage.value);
return;
}

const coupon = validationData.coupon;
const baseAmount = getCouponBaseAmount(coupon);
const lineUpdates = validationData.line_updates || [];
const totalDiscount = Number.parseFloat(validationData.total_discount || 0);

// Check minimum amount on the configured coupon base
if (coupon.min_amount && baseAmount < coupon.min_amount) {
errorMessage.value = __("This coupon requires a minimum purchase of ", [
formatCurrency(coupon.min_amount),
]);
if (!lineUpdates.length || totalDiscount <= 0) {
errorMessage.value =
validationData?.message || __("No eligible items for this coupon");
showWarning(errorMessage.value);
return;
}

// Calculate discount on subtotal (before tax) using centralized helper
// Transform server coupon format to discount object format
const discountObj = {
percentage: coupon.discount_type === "Percentage" ? coupon.discount_percentage : 0,
amount: coupon.discount_type === "Amount" ? coupon.discount_amount : 0,
};

let discountAmount = calculateDiscountAmount(discountObj, baseAmount);

// Apply maximum discount limit if specified
if (coupon.max_amount && discountAmount > coupon.max_amount) {
discountAmount = coupon.max_amount;
}

// Clamp discount to the selected coupon base to prevent negative totals
discountAmount = Math.min(discountAmount, baseAmount);

appliedDiscount.value = {
name: coupon.coupon_name || coupon.coupon_code,
code: couponCode.value.toUpperCase(),
code: (coupon.coupon_code || couponCode.value).toUpperCase(),
percentage: coupon.discount_type === "Percentage" ? coupon.discount_percentage : 0,
amount: discountAmount,
amount: totalDiscount,
type: coupon.discount_type,
coupon: coupon,
apply_on: coupon.apply_on,
base_amount: baseAmount,
line_updates: lineUpdates,
eligible_item_codes: validationData.eligible_item_codes || [],
eligible_subtotal: validationData.eligible_subtotal || 0,
application_mode: "line",
};

emit("discount-applied", appliedDiscount.value);
Expand Down
Loading
Loading