Skip to content

fix(offers): preserve item discount on submit + harvest transaction-level rules#263

Merged
engahmed1190 merged 3 commits into
developfrom
fix/promotional-offers-end-to-end
May 14, 2026
Merged

fix(offers): preserve item discount on submit + harvest transaction-level rules#263
engahmed1190 merged 3 commits into
developfrom
fix/promotional-offers-end-to-end

Conversation

@engahmed1190
Copy link
Copy Markdown
Contributor

Summary

Two related bugs surfaced from a real user report: a Sales Invoice ended up Partly Paid even though the cashier collected exactly the discounted amount the cart displayed. Diagnosis revealed two distinct issues in the offer engine that together cause every offered invoice to come out wrong.

Bug 1 — Item-level discount silently wiped on submit ("Partly Paid" bug)

When POS Next sets invoice.ignore_pricing_rule = 1 (which it always does, since offers are computed via apply_offers and POS Next handles discount fields manually) AND item.pricing_rules carries the offer name AND the doc already exists in DB, ERPNext's get_pricing_rule_for_item branches into remove_pricing_rule_for_item, which zeroes discount_percentage, discount_amount, and resets rate = price_list_rate on the next save.

  • 1st save (update_invoice → draft): doc has no name yet → branch doesn't fire → discount survives ✓
  • 2nd save (submit_invoice → reloads + saves submitted): doc exists → branch fires → discount silently zeroed ✗

Result: grand_total reverted to the pre-discount price; paid_amount stayed at the (discounted) amount the cashier collected; difference shows as outstanding_amountstatus = Partly Paid.

Fix (pos_next/api/invoices.py:_process_invoice): clear item.pricing_rules before save. The discount is still preserved via discount_percentage / discount_amount / rate on the line — those fields are the source of truth, and the empty pricing_rules no longer triggers ERPNext's removal branch.

Bug 2 — Transaction-level Price discounts silently dropped

_evaluate_transaction_offers only harvested free-item rows after running ERPNext's apply_pricing_rule_on_transaction. When a Price-type Transaction rule fired (e.g., "10% off cart over 100 SAR"), ERPNext writes additional_discount_percentage / discount_amount / apply_discount_on on the doc header — and POS Next never read them. The cart received applied_pricing_rules: [] and never saw the discount.

Fix (pos_next/api/invoices.py:_evaluate_transaction_offers + apply_offers): snapshot the header fields before/after the engine call, surface them in the response dict, attribute the change to matching transaction-level Price rules so they appear in applied_pricing_rules. Frontend (POS/src/stores/posCart.js) writes the harvested discount_amount into additionalDiscount.value via a new applyHeaderDiscountFromServer helper. The existing submit path already maps additionalDiscount.valueinvoice.discount_amount, so no other plumbing needed.

Test plan (automated)

Adds pos_next/test_promotions.py18 FrappeTestCase methods that drive the full apply_offers → update_invoice → submit_invoice pipeline and assert the saved invoice has the expected totals + status:

  • 7 main shapes: Discount Percentage, Discount Amount, Rate override, Free same-item, Free different-item, Transaction-level, Mixed multi-item
  • 6 edge cases: 100% off, discount = item price, multi-qty, stacked line + transaction, transaction below min_amt, plain no-offer invoice
  • 2 regression canaries: test_partial_paid_regression + test_transaction_harvest_response pin down the exact behaviors fixed here
  • 4 negative tests: disabled rule, expired rule, wrong item scope, transaction below min_amt — must yield empty applied_pricing_rules

The previously-commented Run Tests step in .github/workflows/ci.yml is now enabled and scoped to this module, so the suite gates every PR.

Local invocation on dev sites (where bench run-tests would wipe data):

bench --site <site> execute pos_next.test_promotions.run_all

5 consecutive local runs against nexus.local: 18/18 passing, deterministic.

Test plan (reviewer)

  • Pull the branch and run bench --site <your-dev-site> execute pos_next.test_promotions.run_all; confirm 18/18 OK
  • Reproduce the original "Partly Paid" bug on a stash of pre-fix code: apply a Discount Percentage Pricing Rule, add the item to cart, pay only the discounted amount → see Partly Paid
  • Apply this branch, re-run the flow → status = Paid, outstanding_amount = 0
  • Set up a Transaction-level rule (e.g. "10% off cart over 100 SAR"), check that the cart total reflects the discount and the saved invoice carries discount_amount / additional_discount_percentage / apply_discount_on = "Grand Total"
  • Confirm CI green on this PR (the new Run Promotion Tests step)

🤖 Generated with Claude Code

engahmed1190 and others added 3 commits May 14, 2026 19:49
…evel rules

Two related bugs surfaced from a user report of a Sales Invoice ending up
"Partly Paid" even though the cashier collected the discounted amount. Both
fixes are needed to make every Pricing Rule shape POS Next claims to support
behave correctly end-to-end (apply_offers → update_invoice → submit_invoice).

1. Partial-paid regression
   When POS Next set `ignore_pricing_rule=1` on the invoice doc (which it
   always does, since offers are computed manually via apply_offers) AND
   item.pricing_rules carried the offer name AND the doc already existed in
   DB, ERPNext's get_pricing_rule_for_item branched into
   remove_pricing_rule_for_item, which zeroed discount_percentage,
   discount_amount, and reset rate=price_list_rate on the next save.

   The first save (saveDraft) didn't trip it because the doc didn't exist
   yet; the second save (submit) did, silently restoring the pre-discount
   grand_total while paid_amount stayed at the collected (discounted)
   amount — producing "Partly Paid" status with a phantom outstanding equal
   to the discount.

   Fix: clear item.pricing_rules in _process_invoice before save. The
   discount itself is still carried via discount_percentage /
   discount_amount on the line, so the offer's effect is preserved.

2. Transaction-level Price discounts silently dropped
   _evaluate_transaction_offers only collected free-item rows after running
   ERPNext's apply_pricing_rule_on_transaction. When a Price-type
   Transaction rule fired (e.g. "10% off cart over 100"), ERPNext wrote
   additional_discount_percentage / discount_amount / apply_discount_on on
   the doc header — and POS Next never read them back. The cart received
   no signal that any rule applied, leaving applied_pricing_rules empty.

   Fix: snapshot the header fields before/after the engine call, surface
   them in the return dict, attribute the change to matching transaction-
   level Price rules in rule_map. apply_offers() forwards them to the
   client; the cart writes discountAmount into additionalDiscount.value,
   which the existing submit path already maps to invoice.discount_amount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… in CI

Adds pos_next/test_promotions.py — 18 FrappeTestCase methods that drive the
full apply_offers → update_invoice → submit_invoice pipeline and assert
the saved Sales Invoice has the expected grand_total / paid_amount /
discount_percentage / discount_amount / outstanding_amount / status.

Coverage:
- 7 main offer shapes: Discount Percentage, Discount Amount, Rate override,
  Free same-item, Free different-item, Transaction-level, Mixed multi-item.
- 6 edge cases: 100% off, discount equal to item price, multi-qty,
  stacked line + transaction discounts, transaction rule below min_amt,
  plain (no-offer) invoice.
- 2 explicit regression canaries:
  * test_partial_paid_regression — pins down the ERPNext
    remove_pricing_rule_for_item interaction so future ERPNext upgrades or
    POS Next refactors that break it fail loudly.
  * test_transaction_harvest_response — pins down the apply_offers
    response shape so callers can rely on additional_discount_percentage /
    discount_amount / apply_discount_on being surfaced.
- 4 negative tests: disabled rule, expired rule, wrong item scope,
  transaction rule below threshold — all must yield empty
  applied_pricing_rules and zero discount.

Test fixtures are deterministic: all items / customer / POS Profile /
Pricing Rules are prefixed _PNXT_TEST_ and created on demand, so the suite
runs cleanly on a fresh CI test_site or an existing dev site (data is
left in place at teardown — rules are disabled rather than deleted).

The previously-commented Run Tests step in .github/workflows/ci.yml is
enabled and scoped to this module so the integration suite gates every PR.
Local invocation on dev sites (where bench run-tests would wipe data):
  bench --site <site> execute pos_next.test_promotions.run_all

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CI run on the new test_site revealed that the test setUp leaned on
fixtures that exist only when a site has been seeded (nexus.local) but not
on the bare ERPNext install CI provisions.

Failures fixed:

1. 'All Customer Groups' is a Group node — Customer.validate rejects it.
   Resolve a leaf Customer Group via is_group=0, preferring ERPNext's
   _Test Customer Group fixture when present, falling back to creating
   one under the root.

2. Same gotcha applies to 'All Territories'. Added _resolve_territory()
   with the same is_group=0 + fallback-create pattern.

3. Item Group: 'All Item Groups' is also a Group node. Added
   _resolve_item_group() with the same shape.

4. Mode of Payment: a fresh test_site has the 'Cash' Mode of Payment but
   no Mode of Payment Account rows, so _set_payment_accounts would fail
   later. Wire up Cash → company.default_cash_account (or any cash-type
   leaf account, or any leaf account as last resort) if no Mode of
   Payment Account exists for the test company.

All 18 tests still pass against nexus.local (3 consecutive runs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@engahmed1190 engahmed1190 merged commit 0847b39 into develop May 14, 2026
3 checks passed
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.

1 participant