Skip to content

Feature/thin develop split - #360

Open
MohamedAliSmk wants to merge 24 commits into
developfrom
feature/thin-develop-split
Open

Feature/thin develop split#360
MohamedAliSmk wants to merge 24 commits into
developfrom
feature/thin-develop-split

Conversation

@MohamedAliSmk

Copy link
Copy Markdown
Collaborator

No description provided.

MohamedAliSmk and others added 7 commits August 18, 2026 14:06
…evelop.

Keep origin/develop baseline offers; GWP/auth Python stay in posnext_promotions. Free-bundle SI merge is no longer in the POS Next invoice class.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
- Updated App.vue to conditionally render AuthorizationDialog based on authGateInstalled.
- Modified SelectInput.vue to support multiple selections and improved UI for selected options.
- Refactored CreateCustomerDialog.vue to use requiresSplitCustomerName for customer name handling.
- Adjusted InvoiceCart.vue to utilize the new wallet API and removed Magento dependencies.
- Enhanced PromotionManagement.vue to include Gift Pool promotion type and related UI elements.
- Introduced applyOfflineGiftPool function in posCart.js for handling gift pool discounts.
- Updated various API endpoints to ensure compatibility with new promotional features.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Updated useInvoice.js to prevent free items from inflating stock checks and adjusted actual_qty to prefer original_stock.
- Improved posCart.js to utilize new stock validation functions for accurate quantity checks, ensuring free items do not affect paid item stock validation.
- Added utility functions in stockValidator.js for calculating stock quantities and validating item availability based on combined paid and free rows.
- Modified _collect_stock_errors in invoices.py to sum quantities per item_code and warehouse, ensuring accurate stock error reporting.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Added conditional rendering for buttons in InvoiceCart.vue to prevent actions on locked free rows.
- Updated quantity handling logic to ensure free items do not interfere with paid item interactions.
- Introduced isLockedFreeRow function to streamline checks for free item status.
- Enhanced posCart.js to manage free item quantities more effectively, ensuring accurate invoice processing for promotions like GWP.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Updated CreateCustomerDialog.vue to require a selected country code for customer submission and improved mobile number handling.
- Enhanced posCart.js and posOffers.js to include gross and net amount calculations for offer validation, ensuring accurate eligibility checks based on item discounts.
- Refactored customer group and territory resolution logic in customers.py to prefer non-group entries, improving customer creation reliability.

@engahmed1190 engahmed1190 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: architecture, security, performance, coverage

Reviewed against 5d4c16f (merge base a07f981). Verified on a local bench: 3 sites, dns_multitenant: true, 9 gunicorn workers, Frappe + ERPNext v16-era Sales Invoice. JS suite run (vitest run — 12 pass), compileall clean, ruff and biome run on the changed files.

One blocker, four high, nine medium. The blocker is that the split's central mechanism does not work: the optional-app gate is evaluated once per worker process, against whichever site that worker happened to touch first. On this bench a site that does not even have pos_next installed decided pos_next's pricing-rule patch state for a site that does. Details inline on pos_next/__init__.py.

The authorization gate is well-built underneath — the registry/gate/grants/policy separation is clean, consume being compare-and-set rather than delete is the right call and the reasoning in the docstring is correct, and it is the best-tested code in the PR. The problems are at its edges: the approver dialog never shows what is being approved, the rate limit keys on IP rather than approver, PIN enrolment is self-service, and the two audit fields it writes to the invoice are never created.

Not blocking, but worth deciding before merge

  • promoApi.js routes authorization to posnext_promotions.api.authorization.* whenever that app is installed, while before_submit is still enforced by pos_next's gate reading pos_next's redis keyspace. Nothing in this repo can verify that pairing and no test covers it.
  • applyOfflineGiftPool / applyOfflineGwpSameItem (~200 lines of satellite behaviour) are hardcoded in posCart.js and gated at runtime — while this same PR ships offerStrategies.js, a plugin registry built for exactly that.

Coverage

authorization/ is genuinely well tested for its size. The gaps are where the money is: overrides/sales_invoice.py (the largest money-path rewrite here) and posCart.js (+396) have zero tests, and four of the findings below live in that untested code. test_no_promotions_import.py is solid — a real AST walk. test_split_smoke.py mostly skipTests when the optional app is absent, and a couple of cases assert nothing that can fail.

Does POS Next work standalone? Static imports: yes, verified — nothing imports posnext_promotions, magento_integration or masar_miraaya, and the app imports and patches cleanly without them. Runtime: not established, for the two reasons above.

One correction to an earlier draft of this review

I initially flagged get_invoices as having lost its search/date filters and its page-size clamp. That was my error: I diffed against develop's tip rather than the merge base. 98b39e5 improved get_invoices on develop after this branch point; this PR never touched it and the merge will take develop's version. Withdrawn.

Filed as comments, not a change request — the call on merge is yours.

Comment thread pos_next/__init__.py Outdated
Comment thread pos_next/api/wallet.py
Comment thread pos_next/overrides/sales_invoice.py Outdated
{{ __("Authorization Required") }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ __("A manager must approve this action.") }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High — the approver is never shown what they are approving.

This dialog renders "A manager must approve this action.", an approver picker and a PIN box. It never shows the action name, the amount, the customer or the invoice. state.actionLabel is captured in useAuthorization.js and never rendered anywhere.

The refund amount is supplied by the client (ReturnInvoiceDialog.vue, in the requireAuthorization context) and becomes the grant's binding ceiling on the server. So a manager who walks to a till and types a PIN has no way to tell whether they are approving a 12.00 refund or a 12,000.00 one — which is the one thing this control exists to prevent.

Render the action, the amount and return_against in the dialog body.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@MostafaKadry
please review

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@engahmed1190 I think this is more of a UX improvement than a security/correctness issue in our current flow.

The authorization dialog is not shown to the manager on a separate device or in isolation. The cashier calls the manager over to the cashier's terminal, where the full invoice and its details are already visible. The customer is also physically present with the printed invoice, which is required to process the return, so the manager can verify the printed invoice against the invoice shown on the cashier's screen before approving.

The Auth Required dialog is simply the final approval step after those checks have already been performed.

This flow is also exactly what the business team presented to the customer, and it matches the current customer's agreed requirements. So I would not classify the absence of these details in the authorization dialog as a critical security gap. I agree that showing the action, amount, and return reference could be a useful UX improvement, but I don't think it should be treated as a high-severity issue for this flow.

Comment thread pos_next/api/authorization.py Outdated


@frappe.whitelist()
@rate_limit(limit=5, seconds=60)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High — this rate limit keys on IP, so it is one bucket for the whole store.

frappe.rate_limiter.rate_limit defaults to ip_based=True, and no key is passed, so the identity is frappe.local.request_ip. Every till behind one NAT shares a single bucket of 5 approvals per minute. A busy shop hits 429s on legitimate approvals, and the failure mode is a hard block on refunds.

Pass key="approver" or set ip_based=False. pin.max_failures + the lockout is already the brute-force control here, so this decorator does not need to carry that job.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@MostafaKadry
please review

Comment thread pos_next/api/customers.py Outdated
Comment thread pos_next/integrations/registry.py
Comment thread scripts/run_split_smoke_tests.py
Comment thread pos_next/test_split_smoke.py Outdated
Comment thread pos_next/hooks.py
"pos_next.api.wallet.validate_wallet_payment",
"pos_next.overrides.pricing_rule.apply_min_max_price_discounts",
],
"before_submit": "pos_next.authorization.gate.enforce_document",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth documenting somewhere, because it is a behaviour change with no error message that explains it: this hook is unconditional, and the grant token only ever reaches a document through api/invoices.py (invoice_doc.flags[TOKEN_FLAG]).

So once a POS Authorization Rule exists for returns, every other path that submits a POS return — desk UI, bench execute, a background job, a bulk tool — is refused, because it carries no flag. That is fail-closed and probably the right default, but the first person to submit a credit note from the desk will have no idea why.

Related: GRANT_TTL = 180. Any retry of submit_invoice more than three minutes after approval — a slow line, an offline replay via offline/sync.js — needs the manager to walk back to the till.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@MostafaKadry
please review

@engahmed1190

engahmed1190 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

What to finish before re-review

Everything open from review #5141722425, as a checklist. Each item names the file it lives in and the condition that closes it, so "done" is demonstrable rather than asserted.

29 items — 13 must close before re-review (Gates 1–2), 5 tests, 8 cleanups, 3 hygiene.


Gate 1 — Blocking

The split does not currently do what it claims, and money and approval paths are wrong. These are what make the PR unmergeable rather than merely unpolished.

  • B1 — Move the optional-app gate from import time to call time · pos_next/__init__.py:15,27
    Delete _promotions_installed() and the if not _promotions_installed(): wrapper. Guard inside each patched function with optional_apps.promotions_installed(), as overrides/pricing_rule.py and api/sales_invoice_hooks.py already do at request time. The duplicate helper must go too — two copies means a future fix lands in one and misses the other.
    Done when: the three-site init sequence shows get_installed_apps is not called during frappe.init(), and a site with posnext_promotions and one without, served by the same worker, each get the correct patch behaviour. Covered by T1.

  • H2 — Give the wallet-mode cache a TTL and an invalidation hook · pos_next/api/wallet.py:182
    As written, ticking is_wallet_payment after the cache warms makes validate_wallet_payment return early — the balance check never runs and a customer can overspend their wallet.
    Done when: toggling is_wallet_payment is reflected on the very next request, with no redis flush.

  • H3 — Stop collapsing company and transaction currency into one variable · pos_next/overrides/sales_invoice.py:124
    Replace payment_amount = flt(base_amount) or flt(amount) with ERPNext's separation: gate on amount, post base_amount. While in there, confirm what happens when the change-amount subtraction above leaves base_amount at exactly 0.
    Done when: a foreign-currency POS profile posts GL entries whose credit/debit are company currency and whose *_in_account_currency are transaction currency, verified on a real submit. Covered by T2.

  • H4 — Show the approver what they are approving · POS/src/components/common/AuthorizationDialog.vue:15
    Render the action, amount, customer and return_against in the dialog body. state.actionLabel is already captured in useAuthorization.js and thrown away. Without this the manager blind-signs a client-supplied number that becomes the grant's binding ceiling.
    Done when: a manager can read the refund amount and source invoice off the dialog without leaving it.

  • H5 — Key the grant rate limit on the approver, not the IP · pos_next/api/authorization.py:95
    Pass key="approver" or set ip_based=False. Today every till behind one NAT shares a bucket of 5/min, and the failure mode is a hard block on refunds during trade. pin.max_failures + lockout already carries the brute-force job.
    Done when: five failed approvals for one approver don't stop a different approver on the same network.

  • M1 — Close self-service PIN enrolment · pos_next/api/authorization.py:163
    Two holes: current_pin is only required when the user already has one, so anyone in an approver Role can self-issue their first PIN; and the else branch accepts has_permission("User", "write"), so anyone who can edit a User can overwrite an approver's PIN and approve as them — with the log naming the legitimate approver.
    Done when: setting another user's PIN requires System Manager, and first enrolment can't be done by the subject alone.


Gate 2 — Correctness, and two open decisions

  • M3 — Decide who owns the authorization gate 🔀 decision · POS/src/utils/promoApi.js:11
    isAuthGateInstalled() is true whenever posnext_promotions is installed, so the frontend calls that app's endpoints while before_submit is still enforced by pos_next's gate reading pos_next's redis keyspace. If the satellite's endpoints are missing or namespace their tokens, the dialog lists no approvers, grants.consume never matches, and returns become unsubmittable. Nothing in this repo can verify the other half.
    Decide: move the gate into the satellite, or make isAuthGateInstalled() reflect the gate rather than the promotions app. Record which, and why, in the PR description.

  • M6 — Move Gift Pool and GWP out of the core cart 🔀 decision · POS/src/stores/posCart.js:1134–1330
    ~200 lines of satellite behaviour in pos_next's cart, runtime-gated on isPromotionsAppInstalled() — in the same PR that ships offerStrategies.js, a registry built for exactly this and used only by Accumulative. Three bugs come along inside applyOfflineGiftPool: the round-robin gift distribution isn't derived from any server rule; paidQty is computed and never used so gifts don't scale with basket size; existingFreeRow.quantity assigns where it should accumulate.
    Decide: if these must stay in core for now, say why the registry doesn't cover them — otherwise the registry is dead weight.

  • M2 — Create the two audit fields, or stop writing to them · pos_next/authorization/gate.py:27-28,102-103
    custom_authorized_by / custom_authorized_at have no fixture, no patch, nothing in install.py. BaseDocument.set writes an unknown fieldname to self.__dict__ and nothing persists, so the invoice records nothing about who approved it.
    Done when: either both fields exist after bench migrate and a gated return shows the approver on the invoice, or the two doc.set lines are gone.

  • M4 — Move _validate_pos_payment_accounts() after super().validate() · pos_next/overrides/sales_invoice.py:76,179
    debit_to is assigned inside ERPNext's validate (sales_invoice.py:711,923) and POS never sends it, so the if not self.debit_to: return guard short-circuits on every new invoice.
    Done when: saving a draft whose payment mode points at the receivable account raises immediately, not at submit.

  • M5 — Stop silently re-attributing invoices to the walk-in customer · pos_next/overrides/sales_invoice.py:90
    A renamed or deleted customer falls through to the POS Profile default, booking revenue against the wrong party with nothing saying so.
    Done when: an invoice naming a non-existent customer raises rather than submitting under a different one.

  • M8 — Wire enforce_context to a real action, or delete it · pos_next/authorization/gate.py:64
    Nothing calls it but tests/test_gate.py, yet it's advertised as public API and the registry carries a doctype=None branch to serve it.
    Done when: either one cart-level action is gated end to end, or the function, its registry branch and its docstring mention are removed.

  • M9 — Swap pickle for json; stop the grant TTL refreshing itself · pos_next/authorization/grants.py:40,44,96
    The grant is a flat dict of scalars, so pickle buys nothing and costs a code-execution primitive on anything reachable from redis. Separately, _write sets ex=GRANT_TTL on every consume, restarting the 180s window.
    Done when: grants serialise as JSON, and a grant issued 180s ago is dead however many times it was consumed.


Gate 3 — Tests

authorization/ is genuinely well tested. Everything else here that touches money is not, and that's exactly where the findings clustered.

  • T1 — A test that would have caught B1 · new, alongside test_no_promotions_import.py
    Assert the gating is evaluated per call, not per process: importing pos_next issues no get_installed_apps query, and patch behaviour follows the current site rather than the first one initialised.

  • T2 — Cover overrides/sales_invoice.py · new, pos_next/overrides/test_sales_invoice.py
    The largest money-path rewrite in the PR has no tests, and H3, M4 and M5 all live in it. Minimum: GL for a cash sale, a foreign-currency profile and a return; the payment-account guard firing on save; a non-existent customer raising.

  • T3 — Pin the GWP carve/restore round trip · new, POS/src/stores/__tests__/posCart.spec.js
    Today this invariant holds only because four call sites agree. Test that carving a free row then removing the offer restores the paid quantity exactly, and that a draft save/load preserves it.

  • T4 — Replace the assertions in test_split_smoke.py that cannot fail · pos_next/test_split_smoke.py:85 and nearby
    assertIsInstance(result, bool) passes whether the hook chain works or not; test_bootstrap_settings_hook_extends_flags asserts keys it inserted itself a line earlier. With the skipTests, the suite is green on a bench with none of the optional apps installed while proving almost nothing. test_no_promotions_import.py is the model — its AST walk is a real check.

  • T5 — Prove the offline cart and the server agree on gifts · new
    The offline round-robin over pool SKUs is a guess at server behaviour. Without parity, an offline sale can hand out a different free product than the same basket would online. Do this wherever the logic ends up after M6.


Gate 4 — Cleanups

  • L1clear_authorization_pin logs a removal as RESULT_PIN_SET; the audit trail records the opposite of what happened · api/authorization.py:185
  • L2 — Unreachable branch in _resolve_leaf_territory: by the time control reaches it, name either doesn't exist or is a leaf, and the leaf case already returned · api/customers.py:229
  • L3after_customer_insert has no try/except, so a best-effort external sync can roll back a customer already inserted locally · integrations/registry.py:61
  • L4 — Redundant sync_return_loyalty_program in on_submit; validate already called it before db_update, so nothing this changes persists · overrides/sales_invoice.py:81-87
  • L5 — Smoke-test runner hardcodes site brainwise.dev; also the sys.path.insert after import frappe does nothing · scripts/run_split_smoke_tests.py:20
  • L6 — Note the memory ceiling on the full tabItem Price snapshot so nobody enables the flag on a large catalogue by surprise · overrides/test_setup_compat.py
  • L7ruff check --fix clears I001 in customers.py:6, pricing_rule.py:24, sales_invoice.py:10, plus RUF100/UP038 in test_split_smoke.py
  • L8Confirm the removal of _combine_packed_qty_for_free_product_bundles is covered · overrides/sales_invoice.py
    This PR deletes the method that merged duplicate packed rows for free bundle lines. If patch_packed_item_keying now covers that case, test_packed_items_regression.py should demonstrate it; if it doesn't, the deletion is a regression nobody has noticed yet.

Gate 5 — Before asking for re-review

  • D1 — Write the PR description. Six thousand lines reorganising how three apps compose, currently with an empty body. It needs: what the split is and where the boundary now sits; which apps are expected alongside it and which are optional; how the two Gate 2 decisions went; and a manual test plan for a site with the satellites and a site without.
  • D2 — Document the fail-closed return behaviour · hooks.py:161
    Once a POS Authorization Rule exists, every path that submits a POS return other than api/invoices.submit_invoice — desk UI, bench execute, background jobs, bulk tools — is refused, because only that path sets the token flag. Probably the right default; documented nowhere, and the error message doesn't explain it. Same note should carry GRANT_TTL = 180: a retry after a slow line or an offline replay needs the manager to walk back to the till.
  • D3 — Rebase on develop and re-run everything. develop has moved since merge base a07f981 (98b39e5 reworked get_invoices, among others). Rebase so the diff shown is the diff that will merge.

Definition of done

Run these and paste the output here. Green, plus Gates 1–2 closed, is what makes this ready for a second look.

cd POS && yarn test:run              # currently 12 pass — should grow with T1–T5
bench --site <site> run-tests --app pos_next
ruff check pos_next scripts
cd POS && yarn biome lint src

# and the one that proves B1, on a bench with 2+ sites:
python -c "import frappe; frappe.init(site='A'); frappe.destroy(); frappe.init(site='B')"
# -> get_installed_apps must not be called during either init

Deliberately not on this list: the repo-wide Biome formatting drift (predates this PR), and get_invoices — I reported that as a regression and was wrong. It was improved on develop after this branch point, and this PR never touched it.

MohamedAliSmk added 3 commits September 8, 2026 15:57
Import-time _promotions_installed() froze the patch gate to whichever
site warmed a multi-tenant worker first, causing silent wrong prices or
double-patching across sites. Always install the patches and defer via
optional_apps.promotions_installed() on each call.
Stop collapsing base_amount and amount into one payment_amount — credit/debit must stay company currency, and a zero base after change netting must skip instead of falling back to the txn amount.
…ion.

- Added expires_in_sec=300 as a safety net on pos_next_wallet_payment_modes
- Clear the key from Mode of Payment after_insert / on_update / on_trash

Toggling is_wallet_payment now refreshes the map immediately, so validate_wallet_payment still runs the balance check. TTL covers any missed invalidation path without needing a Redis flush.
@engahmed1190 engahmed1190 mentioned this pull request Sep 8, 2026
4 tasks
@engahmed1190

Copy link
Copy Markdown
Contributor

Cross-reference: this PR and #318

Both are open against develop and both touch the same four files, so they need sequencing rather than parallel merges.

Shared files

File #360 adds #318 adds
POS/components.d.ts AuthorizationDialog ExpenseDialog
POS/src/pages/POSSale.vue menu button + dialog mount menu button + dialog mount
POS/src/components/sale/InvoiceCart.vue wallet/free-item changes expense button
pos_next/api/bootstrap.py authorization_policy, authorization_pin_length 2 keys on result["pos_profile"]

components.d.ts will conflict outright — each registers a new component. The other three are textually adjacent rather than contradictory, but all four want a human resolving them.

Suggested order: #318 first, then this one rebases onto it.

Not a judgement on quality — it is about how far each one's blocker is from done:

  • Pn 65 expenses in pos screen #318: the blocker is a config decision — an expense maximum that ships with no default, where <= 0 is read as "unlimited". Five Gate 1 items, all inside one module.
  • Feature/thin develop split #360 (this PR): the blocker is architectural — the optional-app gate is evaluated once per worker process against an arbitrary site, so the split's central mechanism does not currently engage. That is a rework, and Gate 2 carries two ownership decisions on top (who owns the authorization gate, and whether Gift Pool / GWP belong in the core cart).

Since this PR is the one staying open longer and already needs a rebase, it is the natural place to absorb the four-file conflict.

When you rebase: after resolving, confirm both the Return authorization and the POS Expense entries appear in the POS menu — the two features add sibling buttons to the same menu and the same dialog mount point, so a bad merge silently drops one.

One thing worth knowing before you rebase: develop has moved 17 commits past #318's merge base, and commit 98b39e5 reworked get_invoices there. I initially flagged that function as a regression in this PR and withdrew it — this PR never touched it, and the rebase will simply take develop's version.

Reviews and per-PR checklists are in the comments on each. Item counts: 29 here, 21 on #318.

@engahmed1190

Copy link
Copy Markdown
Contributor

Review board — PRs #318 and #360

Everything open across both PRs, in one place. 50 items. Full detail is in each PR's review and checklist comment; this is the consolidated view.

Gate 1 Gate 2 Tests Cleanup + hygiene Total
#318 POS expenses 5 7 4 5 21
#360 Thin develop split 6 7 5 11 29

Merge sequence

First — land #318. Its blocker is a one-line config decision. Five Gate 1 items, all local to one module. Nothing in it depends on #360.

Then — rebase #360 onto it. #360 resolves the four-file collision, since it is the one still needing architectural work. Merging it first would make #318 pay a cost it did not incur.

Throughout — neither ships without its blocker closed. Both blockers are unbounded-authority defects, not polish. Everything else on this board can follow.


The two blockers

Everything else here is secondary to these.

#318 — A blank expense maximum means unlimited

Tick one checkbox on a POS Profile and every cashier on it can submit unbounded Journal Entries crediting the till, through a whitelisted endpoint running ignore_permissions=True. posa_maximum_expense_amount ships with no default, and <= 0 is read as "no limit".

custom/pos_profile.json:342 · api/expenses.py:134

#360 — The optional-app gate never engages

Decided once per worker process against whichever site it touched first. Reproduced on a 3-site bench: fleet.localhost, which does not have pos_next installed, decided pos_next's pricing-patch state for posnext.local, which does. The split's central mechanism does not do what it claims.

pos_next/__init__.py:27


#318 · POS expenses — 21 items

Closer to mergeable. Validators are thorough and tested; 17/17 pass. The gaps are all downstream of the money leaving.

Gate 1 — Blocking (5)

  • B1 Blank maximum must mean "not configured", not unlimited · custom/pos_profile.json:342, api/expenses.py:134
  • H1 Lock the shift around check-and-insert · api/expenses.py:127
  • H2 Sum the credit rows, not the writable custom field · api/expenses.py:154
  • H3 Justify and narrow the two ignore_permissions bypasses · api/expenses.py:273, 307
  • H4 Add a cancel path, refused once the shift closes · api/expenses.py:368

Gate 2 — Correctness (7)

  • M1 Scope the report by company; fix or drop the inert cashier role · pos_expense_report.py:96
  • M2 Link the closing-shift row to its Journal Entry; Data → Link · pos_closing_shift_expense.json
  • M3 Stop resolving the payment account to an arbitrary ledger · api/expenses.py:249
  • M4 🔀 decide — whether non-cash modes may fund an expense · api/expenses.py:204
  • M5 Post overnight expenses to the shift's date · api/expenses.py:331
  • M6 Stop overloading cheque_no to carry the shift · api/expenses.py:334
  • M7 Handle multi-currency on the Journal Entry · api/expenses.py:350

Gate 3 — Tests (4)

  • T1 One test that builds a real Journal Entry — covers M3, M5, M7 at once
  • T2 Cover the "no maximum" behaviour that hid B1
  • T3 Replace the two change-detector tests · test_expenses.py:159, 213
  • T4 Cover the reconciliation seam in make_closing_shift_from_opening

Gate 4–5 — Cleanup & hygiene (5)

  • L1 Dead @expense-created emit — nothing refreshes · POSSale.vue:590
  • L2 Hardcoded "USD" currency default · ExpenseDialog.vue:146
  • L3 Unbounded account list — server-side search instead · api/expenses.py:307
  • D1 Rebase — 17 commits behind develop
  • D2 Coordinate the four-file collision with Feature/thin develop split #360

#360 · Thin develop split — 29 items

Further out. The auth gate underneath is well built; the problems are at its edges and in the split mechanism itself.

Gate 1 — Blocking (6)

  • B1 Move the optional-app gate to call time; delete the duplicate helper · pos_next/__init__.py:27
  • H2 TTL and invalidation on the wallet-mode cache · api/wallet.py:182
  • H3 Stop OR-ing company and transaction currency · overrides/sales_invoice.py:124
  • H4 Show the approver the action, amount and invoice · AuthorizationDialog.vue:15
  • H5 Key the rate limit on approver, not IP · api/authorization.py:95
  • M1 Close self-service PIN enrolment · api/authorization.py:163

Gate 2 — Correctness & decisions (7)

  • M3 🔀 decide — who owns the authorization gate · POS/src/utils/promoApi.js:11
  • M6 🔀 decide — move Gift Pool and GWP out of the core cart · posCart.js:1134–1330
  • M2 Create the two audit fields, or stop writing to them · authorization/gate.py:102
  • M4 Move payment-account validation after super().validate() · overrides/sales_invoice.py:76
  • M5 Stop silently re-attributing invoices to the walk-in customer · overrides/sales_invoice.py:90
  • M8 Wire enforce_context to a real action, or delete it · authorization/gate.py:64
  • M9 picklejson; stop the grant TTL refreshing itself · authorization/grants.py:40, 96

Gate 3 — Tests (5)

  • T1 A test that would have caught B1
  • T2 Cover overrides/sales_invoice.py — currently zero
  • T3 Pin the GWP carve/restore round trip
  • T4 Replace the smoke-test assertions that cannot fail
  • T5 Prove the offline cart and server agree on gifts

Gate 4–5 — Cleanup & hygiene (11)

  • L1 PIN removal logged as RESULT_PIN_SET · api/authorization.py:185
  • L2 Unreachable branch in _resolve_leaf_territory · api/customers.py:229
  • L3 Failing external sync rolls back a created customer · integrations/registry.py:61
  • L4 Redundant sync_return_loyalty_program in on_submit
  • L5 Smoke runner hardcodes site brainwise.dev
  • L6 Note the memory ceiling on the Item Price snapshot
  • L7 Clear ruff on the changed files
  • L8 Confirm the deleted packed-bundle merge is covered
  • D1 Write the PR description — body is empty
  • D2 Document the fail-closed return behaviour and the 180s TTL
  • D3 Rebase on develop

The collision

Both PRs touch POS/components.d.ts, POS/src/components/sale/InvoiceCart.vue, POS/src/pages/POSSale.vue and pos_next/api/bootstrap.py. The first will conflict outright — each registers a new component. Whoever merges second resolves all four by hand and re-checks that both features still appear in the POS menu.

Four decisions, not patches

Marked 🔀 above. Each wants a person, and each belongs in the PR description once settled.

  1. Feature/thin develop split #360 — who owns the authorization gate (pos_next or the satellite).
  2. Feature/thin develop split #360 — whether Gift Pool and GWP belong in the core cart, given offerStrategies.js exists for exactly that.
  3. Pn 65 expenses in pos screen #318 — whether non-cash modes may fund an expense.
  4. Pn 65 expenses in pos screen #318 — what a blank expense maximum should mean.

What I withdrew

Two findings did not survive checking, and are deliberately not on this board:

  • Feature/thin develop split #360get_invoices. I reported it as a regression that lost search, date filters and the page-size clamp. Wrong: I diffed develop..pr360 instead of the merge base. Commit 98b39e5 improved it on develop after this branch point; the PR never touched it and the rebase will take develop's version.
  • Pn 65 expenses in pos screen #318 — the report's POSNext Cashier grant. It looked like cross-company exposure. It isn't: frappe.desk.query_report.run also checks has_permission("Journal Entry", "report") (frappe/desk/query_report.py:207), and that role holds no Journal Entry permission on this bench, with none added by the shipped fixtures. A cashier gets refused. The inert role grant is filed as a config bug inside M1; the real half is the missing company scoping.

#318 @ 04034eb (merge base 9212350) · #360 @ 5d4c16f (merge base a07f981). Verified on a local bench: Frappe + ERPNext, 3 sites, dns_multitenant. #318 backend 17/17 pass; #360 vitest 12/12 pass; ruff and biome run on both.

MohamedAliSmk added 9 commits September 8, 2026 16:18
_ensure_pos_customer() stays before ERPNext’s validate (customer is still required up front). The payment-account check now runs once debit_to exists, so it catches same-account Mode of Payment issues on first save instead of only on submit re-validate.
- **Blank customer** → still falls back to the POS Profile default (walk-in convenience unchanged)
- **Non-empty customer that doesn’t exist** → throws `Invalid Customer` instead of quietly swapping to the default
- **Neither resolves** → still returns `None`, and `_ensure_pos_customer` throws as before
pos_next

Extended offerStrategies.js with registerProductOfferStrategy / getProductStrategyForOffer (keyed by promotion_type)
applyOfflineFreeItem only asks the registry; removed applyOfflineGiftPool, applyOfflineGwpSameItem, and the isPromotionsAppInstalled gates from posCart.js
posnext_promotions

Added public/pos/offer-strategies.js — registers Gift Pool + GWP with the same offline behaviour as before (round-robin, once-per-cart paidQty gate, absolute free-row qty)
Tests: offerStrategies vitest (6) and test_no_promotions_import (9) pass.
Extract syncCartFreeItems so free-row qty restore isn't only enforced by call-site agreement, and add round-trip tests for the silent undercharge case.
…mmediately; group or missing names fall through to the next candidate, then any leaf, then "All Territories".
…g the loyalty/bootstrap runners.

Left alone on purpose:

validate_customer_create — must raise
prepare_customer_doc — runs before insert(), so a failure never leaves an orphaned local Customer; keeping it strict still makes sense unless you want prepare to be best-effort too
Hardcoding brainwise.dev broke other benches; insert apps on sys.path before importing frappe so the path change actually applies.
…when optional apps are missing.

Replaced tautologies

prepare_customer_doc: AST check that it reads pos_next_customer_prepare, plus mocked hooks proving empty → False, falsy → False, truthy → True (and that kwargs are forwarded).
extend_bootstrap_settings: mocked hook must mutate a fresh {}; empty hooks leave settings unchanged.
Dropped skip-heavy “when installed” checks (doc events, LP API, promotions override) — those belong in the satellite apps. Replaced with a static assert that pos_next/hooks.py does not register Magento handlers.

Kept wallet mocks, no-magento-module import, and bootstrap defaults from DEFAULT_POS_SETTINGS.
…sts doesn’t export it). That import was only in test_bank_deposits.py.
@MohamedAliSmk

Copy link
Copy Markdown
Collaborator Author

Collision with #318 — agreed merge order

Land #318 first. This PR (#360) rebases second and resolves the four-file collision.

File From #318 (must survive rebase) From #360 (must keep)
POS/components.d.ts ExpenseDialog AuthorizationDialog
POS/src/pages/POSSale.vue POS Expense menu + dialog mount :company on InvoiceCart
POS/src/components/sale/InvoiceCart.vue POS Expense empty-cart button wallet / free-item / promo changes
pos_next/api/bootstrap.py expense profile keys auth policy / pin length / timezone / extend_bootstrap_settings

After rebase — menu smoke check

  • POS Expense in header menu and InvoiceCart empty-cart grid
  • Return Invoice still present; gated return still opens AuthorizationDialog (App.vue)
  • Promotions still in ManagementSlider

components.d.ts: keep both registrations. Do not take only one side of the conflict.

Blank approver: Rejected before processing.
PIN lockout: Now fail-closed if Redis/cache is unavailable.
Cache outage bugs: Fixed; logging is now best-effort and non-blocking.
429 UX: Replaced Frappe’s technical message with a clear user-facing message.
Verification: 63 backend + 36 frontend tests passed; 9/9 split guards; build clean.
Trade-off: Redis outage blocks approvals intentionally for security.
Deployment note: Ensure X-Forwarded-For preserves the real client IP behind any LB/CDN.
…nal framework operations, preventing unrelated back-office documents from being blocked.

Grant TTL: Increased from 3 to 15 minutes, configurable via POS Authorization Settings.
Security: Longer TTL does not weaken grant scope; grants remain bound to session, document, action, and amount, and are single-use/revocable.
@MostafaKadry

Copy link
Copy Markdown
Collaborator

@MohamedAliSmk @engahmed1190 All the requested issues and comments have been addressed and thoroughly tested. From my side, all three points have been resolved.

@MohamedAliSmk , is there anything else currently blocking the merge of this branch?

@engahmed1190

Copy link
Copy Markdown
Contributor

@MohamedAliSmk @MostafaKadry, please review the action-item checklist in the comments of PR #360 and check off any items that have already been completed. For any remaining items, please add a brief status update.

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.

3 participants