Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 7 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,10 @@ jobs:
env:
CI: 'Yes'

# - name: Run Tests
# working-directory: /home/runner/frappe-bench
# run: |
# bench --site test_site set-config allow_tests true
# bench --site test_site run-tests --app pos_next
# env:
# TYPE: server
- name: Run Promotion Tests
working-directory: /home/runner/frappe-bench
run: |
bench --site test_site set-config allow_tests true
bench --site test_site run-tests --module pos_next.test_promotions
env:
TYPE: server
60 changes: 50 additions & 10 deletions POS/src/stores/posCart.js
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,8 @@ export const usePOSCartStore = defineStore("posCart", () => {
* Extracts and normalizes the offer response from backend
*
* @param {Object} response - Raw API response from backend
* @returns {Object} Normalized response with items, freeItems, and appliedRules
* @returns {Object} Normalized response with items, freeItems, appliedRules,
* and headerDiscount (transaction-scope discount).
*
* IMPORTANT: No fallback for appliedRules - we trust the backend's response.
* If backend returns empty applied_pricing_rules, it means NO offers were applied.
Expand All @@ -462,10 +463,40 @@ export const usePOSCartStore = defineStore("posCart", () => {
freeItems: Array.isArray(payload.free_items) ? payload.free_items : [],
// CRITICAL: Only trust explicitly returned rules - NO FALLBACK
// If backend doesn't return applied_pricing_rules, NO offers were applied
appliedRules: Array.isArray(payload.applied_pricing_rules) ? payload.applied_pricing_rules : []
appliedRules: Array.isArray(payload.applied_pricing_rules) ? payload.applied_pricing_rules : [],
// Header-level (transaction-scope) discount surfaced by the server when an
// apply_on=Transaction Price rule fires. discountAmount is the resolved
// SAR amount (already computed from % if the rule is percentage-based).
// Zero/empty when no such rule applies.
headerDiscount: {
discountAmount: Number.parseFloat(payload.discount_amount) || 0,
applyDiscountOn: payload.apply_discount_on || null,
},
}
}

/**
* Apply (or clear) the header-level discount the server surfaced when an
* apply_on=Transaction Price rule fired. ERPNext stores this on the invoice
* header as discount_amount + apply_discount_on; in the cart we mirror it
* via additionalDiscount.value (which is sent back as discount_amount in
* the invoice payload — see useInvoice.js#submitInvoice).
*
* Pass an empty/zero headerDiscount to clear (e.g. when no transaction-level
* rule applies on the current cart).
*/
function applyHeaderDiscountFromServer(headerDiscount) {
if (!headerDiscount) {
additionalDiscount.value = 0
rebuildIncrementalCache()
return
}

const amount = Number.parseFloat(headerDiscount.discountAmount) || 0
additionalDiscount.value = amount
rebuildIncrementalCache()
}

function getAppliedOfferCodes() {
return appliedOffers.value.map((entry) => entry.code)
}
Expand Down Expand Up @@ -527,12 +558,13 @@ export const usePOSCartStore = defineStore("posCart", () => {
// Check if cancelled during API call
if (signal?.aborted) return

const { items: responseItems, freeItems, appliedRules } =
const { items: responseItems, freeItems, appliedRules, headerDiscount } =
parseOfferResponse(response)


applyDiscountsFromServer(responseItems)
processFreeItems(freeItems)
applyHeaderDiscountFromServer(headerDiscount)
filterActiveOffers(appliedRules)

const offerApplied = appliedRules.includes(offerCode)
Expand All @@ -549,10 +581,12 @@ export const usePOSCartStore = defineStore("posCart", () => {
items: rollbackItems,
freeItems: rollbackFreeItems,
appliedRules: rollbackRules,
headerDiscount: rollbackHeaderDiscount,
} = parseOfferResponse(rollbackResponse)

applyDiscountsFromServer(rollbackItems)
processFreeItems(rollbackFreeItems)
applyHeaderDiscountFromServer(rollbackHeaderDiscount)
filterActiveOffers(rollbackRules)
} catch (rollbackError) {
console.error("Error rolling back offers:", rollbackError)
Expand Down Expand Up @@ -669,12 +703,13 @@ export const usePOSCartStore = defineStore("posCart", () => {

if (signal?.aborted) return

const { items: responseItems, freeItems, appliedRules } =
const { items: responseItems, freeItems, appliedRules, headerDiscount } =
parseOfferResponse(response)


applyDiscountsFromServer(responseItems)
processFreeItems(freeItems)
applyHeaderDiscountFromServer(headerDiscount)
filterActiveOffers(appliedRules)

appliedOffers.value = appliedOffers.value.filter((entry) =>
Expand Down Expand Up @@ -782,11 +817,12 @@ export const usePOSCartStore = defineStore("posCart", () => {

if (signal?.aborted) return false

const { items: responseItems, freeItems, appliedRules } =
const { items: responseItems, freeItems, appliedRules, headerDiscount } =
parseOfferResponse(response)

applyDiscountsFromServer(responseItems)
processFreeItems(freeItems)
applyHeaderDiscountFromServer(headerDiscount)
filterActiveOffers(appliedRules)

// Update appliedOffers to only include valid ones
Expand Down Expand Up @@ -1488,7 +1524,7 @@ export const usePOSCartStore = defineStore("posCart", () => {

// All applied offers became invalid and no new offers to apply.
if (combinedCodes.length === 0 && invalidOffers.length > 0) {

appliedOffers.value = []
processFreeItems([])
invoiceItems.value.forEach(item => {
Expand All @@ -1498,6 +1534,9 @@ export const usePOSCartStore = defineStore("posCart", () => {
recalculateItem(item)
}
})
// Also clear any transaction-level header discount the server
// previously surfaced — if no offers remain, no header discount applies.
applyHeaderDiscountFromServer(null)
rebuildIncrementalCache()

const names = invalidOffers.map(o => o.name).join(', ')
Expand All @@ -1512,12 +1551,13 @@ export const usePOSCartStore = defineStore("posCart", () => {
// Check for cancellation or stale operation
if (signal?.aborted || (generation > 0 && generation < cartGeneration)) return

const { items: responseItems, freeItems, appliedRules } = parseOfferResponse(response)
const { items: responseItems, freeItems, appliedRules, headerDiscount } = parseOfferResponse(response)

// 4. Update cart items with new discounts

applyDiscountsFromServer(responseItems)
processFreeItems(freeItems)
applyHeaderDiscountFromServer(headerDiscount)

// 5. Update appliedOffers list based on server confirmation
const actuallyApplied = new Set(appliedRules)
Expand Down
91 changes: 74 additions & 17 deletions pos_next/api/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -848,21 +848,28 @@ def update_invoice(data):
# ERPNext will recalculate if needed, but preserving frontend rate
# prevents rounding issues and ensures UI matches invoice

# Convert pricing_rules from list to comma-separated string
# ERPNext expects pricing_rules as a string, not a list
pricing_rules = item.get("pricing_rules")
if pricing_rules:
if isinstance(pricing_rules, list):
item.pricing_rules = ",".join(str(r) for r in pricing_rules)
elif isinstance(pricing_rules, str) and pricing_rules.startswith("["):
# Handle JSON string representation of list
try:
rules_list = json.loads(pricing_rules)
if isinstance(rules_list, list):
item.pricing_rules = ",".join(str(r) for r in rules_list)
except (json.JSONDecodeError, TypeError):
# Keep original value - malformed JSON will be handled by standardize_pricing_rules
item.pricing_rules = ""
# POS Next computes offers itself (via apply_offers) and sends each
# item with discount_percentage / discount_amount / rate already set.
# We pair that with invoice_doc.ignore_pricing_rule = 1 so ERPNext's
# own pricing engine stays out of the way.
#
# However, ERPNext's get_pricing_rule_for_item() has a branch that
# fires when ignore_pricing_rule=1 AND the doc already exists in DB
# AND item.pricing_rules is non-empty — it interprets that as the
# user disabling pricing rules on an invoice that previously had
# them, calls remove_pricing_rule_for_item(), and silently zeroes
# discount_percentage / discount_amount / rate on the next save.
# That branch fires on the 2nd save (submit step), producing
# "Partly Paid" invoices where the cashier collected the discounted
# amount but the saved grand_total reverted to the pre-discount
# price. See erpnext/accounts/doctype/pricing_rule/pricing_rule.py
# around line 421.
#
# Clearing item.pricing_rules here avoids that branch entirely. The
# discount itself is preserved via the discount_percentage /
# discount_amount fields we already set above.
if item.get("pricing_rules"):
item.pricing_rules = ""

# Set invoice flags BEFORE calculations
if doctype == "Sales Invoice":
Expand Down Expand Up @@ -2761,6 +2768,8 @@ def _evaluate_transaction_offers(
doc.total = total

initial_item_count = len(doc.items)
pre_addl_pct = flt(doc.get("additional_discount_percentage") or 0)
pre_discount_amt = flt(doc.get("discount_amount") or 0)
try:
erpnext_apply_pricing_rule_on_transaction(doc)
except Exception:
Expand All @@ -2769,7 +2778,13 @@ def _evaluate_transaction_offers(
frappe.log_error(
frappe.get_traceback(), "POS Apply Offers (Transaction Rules)"
)
return {"free_items": {}, "applied_rules": set()}
return {
"free_items": {},
"applied_rules": set(),
"additional_discount_percentage": 0,
"discount_amount": 0,
"apply_discount_on": None,
}

free_items = {}
applied_rules = set()
Expand All @@ -2786,7 +2801,39 @@ def _evaluate_transaction_offers(
free_items[(row.item_code, rule_name)] = fid
applied_rules.add(rule_name)

return {"free_items": free_items, "applied_rules": applied_rules}
# Capture header-level discount that ERPNext's apply_pricing_rule_on_transaction
# set on the doc when a Price-type Transaction rule fired. ERPNext writes one of
# additional_discount_percentage / discount_amount onto the doc (see
# erpnext/accounts/doctype/pricing_rule/utils.py:578-616) but does not surface
# which rule fired. We detect "fired" by diffing the doc fields against the
# pre-call snapshot and attribute the application to every selected, in-scope
# transaction-level Price rule in rule_map. The frontend treats the response
# additional_discount_percentage / discount_amount as authoritative for the
# header, so attribution mismatches only affect the UI badge, not totals.
post_addl_pct = flt(doc.get("additional_discount_percentage") or 0)
post_discount_amt = flt(doc.get("discount_amount") or 0)
apply_discount_on = doc.get("apply_discount_on") or None

header_discount_changed = (
post_addl_pct != pre_addl_pct or post_discount_amt != pre_discount_amt
)
if header_discount_changed:
for rule_name, details in rule_map.items():
if selected_offer_names and rule_name not in selected_offer_names:
continue
if details.get("price_or_product_discount") != "Price":
continue
if frappe.db.get_value("Pricing Rule", rule_name, "apply_on") != "Transaction":
continue
applied_rules.add(rule_name)

return {
"free_items": free_items,
"applied_rules": applied_rules,
"additional_discount_percentage": post_addl_pct,
"discount_amount": post_discount_amt,
"apply_discount_on": apply_discount_on,
}


@frappe.whitelist()
Expand Down Expand Up @@ -3217,6 +3264,16 @@ def apply_offers(invoice_data, selected_offers=None):
"items": [dict(item) for item in prepared_items],
"free_items": [dict(item) for item in free_items_map.values()],
"applied_pricing_rules": sorted(applied_rules),
# Header-level (transaction-scope) discount surfaced from
# _evaluate_transaction_offers. Frontend should apply these to the
# invoice header (additionalDiscount + apply_discount_on) when
# present. Both fields are zero when no transaction-level Price
# rule fired.
"additional_discount_percentage": flt(
txn_result.get("additional_discount_percentage") or 0
),
"discount_amount": flt(txn_result.get("discount_amount") or 0),
"apply_discount_on": txn_result.get("apply_discount_on"),
}
except Exception as e:
frappe.log_error(frappe.get_traceback(), "Apply Offers Error")
Expand Down
Loading
Loading