diff --git a/.github/POSTGRES_COMPATIBILITY.md b/.github/POSTGRES_COMPATIBILITY.md index 6cac07afc24..6b08f81fdb4 100644 --- a/.github/POSTGRES_COMPATIBILITY.md +++ b/.github/POSTGRES_COMPATIBILITY.md @@ -45,7 +45,9 @@ Flag a changed query that uses any of these: - **`HAVING` referencing a `SELECT` alias** — PostgreSQL rejects output-column aliases in `HAVING` (regardless of whether the query has a `GROUP BY`; MariaDB allows them). Repeat the underlying expression in `HAVING`, or move a non-aggregate predicate into `WHERE`. -- **`SELECT DISTINCT … ORDER BY `** — add the expr to the select. +- **`SELECT DISTINCT … ORDER BY `** — add the expr to the select + **only if it is single-valued per distinct row**; otherwise it grows the `DISTINCT` key and the + MariaDB row count (see §3) — drop the SQL `ORDER BY` and sort in Python instead. - **Single-quoted column alias** `AS 'x'` — PostgreSQL reads `'x'` as a string literal. Use an unquoted (or double-quoted) alias. - **`varchar | varchar`** (bitwise OR misused as a coalesce) — errors on PostgreSQL. Use @@ -119,7 +121,7 @@ These don't error, so a one-engine CI stays green. Flag them: --- -## 3. The `GROUP BY` row-count trap (the single most important rule) +## 3. The row-count trap — `GROUP BY` **and** `DISTINCT` (the single most important rule) When making a loose `GROUP BY` PostgreSQL-valid, **do not add a non-functionally-dependent column to the `GROUP BY` just to satisfy PostgreSQL** — that turns one group row into N and @@ -140,6 +142,14 @@ versa) to make a number "more correct" — that changes the MariaDB value. The w MariaDB's prior one-value-per-group output; a different aggregate is a product change, out of scope for a portability fix. +**The same trap applies to `SELECT DISTINCT`.** To satisfy PostgreSQL's "an `ORDER BY` expr must +appear in the select list under `DISTINCT`" rule, **do not blindly add the ordered column to the +select** — if it is not single-valued per existing distinct row, the `DISTINCT` key grows and +MariaDB returns **more rows** (a regression), exactly as adding a non-FD column to `GROUP BY` does. +Add it only when it is functionally dependent on the existing select columns; otherwise drop the +SQL `ORDER BY` and **sort in Python** (`key=str.casefold`, per §2) so the distinct row set is +unchanged. + --- ## 4. False positives — do NOT flag these @@ -167,17 +177,44 @@ These are auto-handled by the framework and are **not** breaks: frappe#40075). Such a handler must wrap the fallible insert in `frappe.db.savepoint(name)` + `rollback(save_point=name)` — unless it re-`throw`s with no DB call before the throw, or the insert uses `ignore_if_duplicate=True` / `autoname="hash"` (→ `ON CONFLICT DO NOTHING`). +- **Recover the txn with a *scoped* savepoint, not a full `frappe.db.rollback()`, if any prior work + must survive.** A full rollback un-poisons the txn but also discards every row the handler committed + *before* the failure — which MariaDB kept (it has no statement-abort), so it's a **silent MariaDB + regression**. **"The background job / whitelist entrypoint owns the txn" does NOT make a full rollback + safe** if it did multiple inserts in a loop first — it drops the partial results MariaDB retained. A + full rollback is safe only when it (a) immediately re-`throw`s/`raise`s (MariaDB rolls back anyway), + (b) has nothing successful before it (a single op), or (c) the batch is genuinely meant to be + **atomic** (a partial result is an invalid state → rollback + mark *Failed* is correct). Otherwise use + a **per-iteration / per-record savepoint** — and keep the function's success/`None` return contract: + do **not** return the doc when the savepoint was rolled back. + +--- + +## 6. Refactors and raw-SQL→ORM conversions are not automatically 1:1 + +A commit labeled a **refactor** or a **raw-`frappe.db.sql` → `frappe.qb`/ORM conversion** is meant +to preserve behaviour — but it easily doesn't, and the change passes the static checker and a +one-engine green run. **Diff the `WHERE`/predicate, the `JOIN`/`ON` conditions, and the resulting +row set — not just the `SELECT` shape.** A conversion that silently widens or narrows the filter +changes the rows touched on **both** engines and is a regression hiding under a "refactor" label. + +Real example: an `UPDATE` whose bound was `posting_datetime > X` gained an +`OR (posting_datetime == X AND creation > args.creation)` branch during a "`sql` → `qb` refactor", +widening the rows updated on both engines. Even when such a change is a deliberate bug-fix it must +be called out and tested — it is **not** the no-op the refactor label implies. Confirm the +converted query touches exactly the same rows with the same values MariaDB produced before. --- ## How to review -For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL), or -(b) match a divergence in §2/§3 (different result across engines)? If so, comment with the +For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL), +(b) match a divergence in §2/§3 (different result across engines), or (c) change the row set under +a refactor/conversion label (§6)? If so, comment with the portable fix and confirm it leaves **MariaDB output unchanged**. Skip the §4 false positives. Prefer a comment that names the rule (e.g. "loose GROUP BY — Max()-wrap, don't add to GROUP BY: splits the row count") so the fix is unambiguous. The static pre-commit checker (`.github/helper/postgres_compat.py`) catches the *mechanical* -§1 breaks; the **semantic** §2/§3 divergences are exactly what a reviewer (and this guide) must -cover, because no static check can see them. +§1 breaks; the **semantic** §2/§3 divergences and the §6 refactor/conversion row-set changes are +exactly what a reviewer (and this guide) must cover, because no static check can see them. diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 34e777506c9..27928ee8bbc 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -297,14 +297,10 @@ if [ "$DB" == "postgres" ];then echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres; echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres; - # Disposable CI DB: durability off for speed (postgres fsyncs every commit by default, which - # dominates a commit-heavy suite). All reloadable, no restart. The postgres workflow runs a - # service-container DB and never calls start-db.sh, so the flags must be applied here. - echo "travis" | psql -h 127.0.0.1 -p 5432 -U postgres \ - -c "ALTER SYSTEM SET synchronous_commit = 'off'" \ - -c "ALTER SYSTEM SET fsync = 'off'" \ - -c "ALTER SYSTEM SET full_page_writes = 'off'" \ - -c "SELECT pg_reload_conf()"; + # Durability-off for speed (no fsync/synchronous_commit/full_page_writes) is applied by + # start-db.sh's postgres `-o` flags on every start — setup job AND each test shard — so it is + # NOT repeated here. The postgres workflow runs in-runner via start-db.sh, not a service + # container. fi cd ~/frappe-bench || exit diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 37d8363beaa..0d2cd148251 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -21,7 +21,7 @@ jobs: cache: pip - name: Install and Run Pre-commit - uses: pre-commit/action@v3.0.0 + uses: pre-commit/action@v3.0.1 semgrep: name: semgrep diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index 40fc667e3d9..83c2d7ff925 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -66,7 +66,7 @@ jobs: run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts # The v14 baseline backup is a fixed published file — cache it instead of re-downloading - # ~100MB from frappe.io every run. + # it from the GitHub release every run. - name: Cache erpnext v14 backup id: cache-v14 uses: actions/cache@v4 @@ -76,7 +76,10 @@ jobs: - name: Download erpnext v14 backup if: steps.cache-v14.outputs.cache-hit != 'true' - run: wget -O ~/erpnext-v14.sql.gz https://frappe.io/files/erpnext-v14.sql.gz + run: | + curl -fSL --retry 5 --retry-all-errors --retry-delay 5 \ + -o ~/erpnext-v14.sql.gz \ + https://github.com/frappe/erpnext/releases/download/v14-baseline/erpnext-v14.sql.gz - name: Cache pip uses: actions/cache@v4 diff --git a/.github/workflows/server-tests-mariadb.yml b/.github/workflows/server-tests-mariadb.yml index 5c417fb9137..3e1d076ce38 100644 --- a/.github/workflows/server-tests-mariadb.yml +++ b/.github/workflows/server-tests-mariadb.yml @@ -107,6 +107,13 @@ jobs: SKIP_SYSTEM_SETUP: "1" SKIP_WKHTMLTOX_SETUP: "1" + - name: Warm up test data + run: | + su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF' + cd ~/frappe-bench/ + bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data + EOF + # Clean shutdown (consistent InnoDB datadir), then stage it inside the bench for packaging. - name: Stop DB and stage datadir run: | diff --git a/.github/workflows/server-tests-postgres.yml b/.github/workflows/server-tests-postgres.yml index 8cd50f235f0..44ab5dfab7a 100644 --- a/.github/workflows/server-tests-postgres.yml +++ b/.github/workflows/server-tests-postgres.yml @@ -105,10 +105,19 @@ jobs: FRAPPE_BRANCH: develop BENCH_CACHE_DIR: /home/runner/bench-cache + - name: Warm up test data + run: | + cd ~/frappe-bench/ + bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data + - name: Stop DB and stage datadir run: | PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1) - "$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop || true + # Clean shutdown so the baked datadir is consistent. Do NOT swallow a failed stop with + # `|| true`: moving and tarring a still-running cluster ships a torn datadir the shards + # cannot crash-recover (full_page_writes is off). Fail the job instead — mirrors the + # MariaDB sister's "don't bake a dirty datadir" guard. + "$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop mv /home/runner/pgdata /home/runner/frappe-bench/pgdata - name: Package bench for test shards @@ -128,7 +137,7 @@ jobs: compression-level: 0 test: - name: Python Unit Tests (PG) + name: Python Unit Tests needs: setup runs-on: ubuntu-latest timeout-minutes: 60 diff --git a/.greptile/config.json b/.greptile/config.json index ed875670346..3c69e6e63fe 100644 --- a/.greptile/config.json +++ b/.greptile/config.json @@ -7,7 +7,7 @@ "frappe/frappe" ] }, - "instructions": "ERPNext runs on both MariaDB and PostgreSQL from one codebase, but the PostgreSQL test job is label-gated and may not run on this PR, so review every new or changed database query (raw frappe.db.sql, frappe.qb, frappe.get_all/get_list/get_value, and report SQL) for cross-engine compatibility. PRIME RULE: MariaDB output must never change; PostgreSQL is bent to match MariaDB, never the reverse, so a change to the value, row count, or ordering MariaDB produced is a regression even if it looks more correct (the only accepted change is replacing an arbitrary/undefined result with a deterministic one, row count preserved, and it should be called out). Flag a changed query that (1) would ERROR on PostgreSQL: loose GROUP BY (selecting/ordering a column neither grouped nor aggregated -- including an aggregate like Sum()/Count() selected next to bare columns with NO .groupby() at all), MySQL-only functions (TIMESTAMP(date,time), TIMEDIFF, STR_TO_DATE, DATE_FORMAT, DATE_ADD/DATE_SUB, GROUP_CONCAT, PERIOD_DIFF, SQL IF()), .rlike()/RLIKE (frappe rewrites REGEXP->~* on PostgreSQL but does NOT translate RLIKE; use .regexp()), .like()/LIKE on a NON-text column such as idx/docstatus (bigint ILIKE has no operator; Cast_(col,'varchar') first), CAST AS CHAR / Cast(x,'char') (bare CHAR is character(1) on PostgreSQL and truncates multi-digit values; use 'varchar'), UPDATE..JOIN, HAVING on a SELECT alias, SELECT DISTINCT with an ORDER BY expr not in the select list, single-quoted column aliases, varchar bitwise OR, capital-cased identifiers used as fieldnames in get_value(dt,dn,'Status') or get_all(dt,fields=['Account']) (PostgreSQL matches the quoted identifier case-sensitively; use the stored lower-case name), a Python bool written to a Check/int column via set_value/db_set/qb.update().set() instead of 1/0, or IfNull/Coalesce of a typed column with a different-typed literal such as IfNull(date_col, 0) -> COALESCE(date, integer) (PostgreSQL: 'COALESCE types date and integer cannot be matched'; the common IfNull(date,0) != 0 / == 0 presence test should be date_col.isnotnull() / .isnull(), else coalesce to a same-type default), or division by a possibly-zero divisor (Sum(a)/Sum(b) or x/col where the data can drive the divisor to 0 -- MariaDB returns NULL for division by zero but PostgreSQL raises 'division by zero' and aborts the query, so wrap the divisor in NullIf(divisor, 0)); or (2) would SILENTLY DIVERGE across engines: case-sensitive ==/.isin()/Strpos on USER-ENTERED free-text columns such as Data/Small Text/Long Text but NOT on Link/Select/name columns where exact-case matching is intended (PostgreSQL is case-sensitive, use Lower() both sides), lowercasing a value used as a document-name lookup, empty-string vs NULL in Concat/Concat_ws (MariaDB CONCAT(x,NULL) is NULL but PostgreSQL CONCAT drops the NULL, so a label like Concat('MFG-', nullable_date) leaks a bare 'MFG-' on PostgreSQL -- guard with Case/Coalesce/NullIf), NULL ordering (PostgreSQL sorts NULLs last) in ORDER BY..LIMIT 1, integer division (int/int truncates on PostgreSQL; multiply by 100.0 or make a literal a float, e.g. col/1440 -> col/1440.0), get_all(distinct=True, order_by=...) (frappe DROPS the ORDER BY for distinct queries on PostgreSQL, so sort in Python with key=str.casefold), an engine-specific function rewrite that does not match MariaDB on edge cases, or UnixTimestamp(date)/date-to-epoch math that is timezone-dependent (a strict epoch <= now bound is flaky on PostgreSQL). Also flag CATCH-AND-CONTINUE inserts: on PostgreSQL a failed insert aborts the WHOLE transaction (InFailedSqlTransaction), so code that swallows a duplicate/unique error and keeps going in the same transaction must wrap the fallible insert in frappe.db.savepoint(name) + rollback(save_point=name), unless it re-throws with no DB call before the throw or the insert uses ignore_if_duplicate=True or autoname='hash'. GROUP BY ROW-COUNT TRAP (most important): to make a loose GROUP BY PostgreSQL-valid, do NOT add a non-functionally-dependent column (the classic traps are the child/row primary key or an editable per-row field) to GROUP BY because that splits one row into N and changes the MariaDB row count; Max()/Min()-wrap it instead (row count preserved, value arbitrary to deterministic). Judge functional dependence by the SOURCE TABLE: a column from a master joined on the group key is FD and safe in GROUP BY, but a descriptive field on the transaction table (e.g. t1.supplier_name, t1.territory) is NOT FD and must be wrapped. Do NOT suggest changing a Max()-wrapped column to Sum() to make a number more correct, that changes MariaDB's value. DO NOT FLAG these false positives: .like()/['like'] on a TEXT column (already ILIKE on PostgreSQL -- but DO flag it on a non-text/integer column, see above), raw ifnull/backticks/LOCATE/REGEXP/.regexp() inside frappe.db.sql (auto-translated by the framework -- but RLIKE/.rlike() is NOT translated, see above), or an ORDER BY..LIMIT 1 tie where adding a tiebreaker would change MariaDB's current pick. Full catalog with examples and portable fixes is in .github/POSTGRES_COMPATIBILITY.md.", + "instructions": "ERPNext runs on both MariaDB and PostgreSQL from one codebase, but the PostgreSQL test job is label-gated and may not run on this PR, so review every new or changed database query (raw frappe.db.sql, frappe.qb, frappe.get_all/get_list/get_value, and report SQL) for cross-engine compatibility. PRIME RULE: MariaDB output must never change; PostgreSQL is bent to match MariaDB, never the reverse, so a change to the value, row count, or ordering MariaDB produced is a regression even if it looks more correct (the only accepted change is replacing an arbitrary/undefined result with a deterministic one, row count preserved, and it should be called out). Flag a changed query that (1) would ERROR on PostgreSQL: loose GROUP BY (selecting/ordering a column neither grouped nor aggregated -- including an aggregate like Sum()/Count() selected next to bare columns with NO .groupby() at all), MySQL-only functions (TIMESTAMP(date,time), TIMEDIFF, STR_TO_DATE, DATE_FORMAT, DATE_ADD/DATE_SUB, GROUP_CONCAT, PERIOD_DIFF, SQL IF()), .rlike()/RLIKE (frappe rewrites REGEXP->~* on PostgreSQL but does NOT translate RLIKE; use .regexp()), .like()/LIKE on a NON-text column such as idx/docstatus (bigint ILIKE has no operator; Cast_(col,'varchar') first), CAST AS CHAR / Cast(x,'char') (bare CHAR is character(1) on PostgreSQL and truncates multi-digit values; use 'varchar'), UPDATE..JOIN, HAVING on a SELECT alias, SELECT DISTINCT with an ORDER BY expr not in the select list, single-quoted column aliases, varchar bitwise OR, capital-cased identifiers used as fieldnames in get_value(dt,dn,'Status') or get_all(dt,fields=['Account']) (PostgreSQL matches the quoted identifier case-sensitively; use the stored lower-case name), a Python bool written to a Check/int column via set_value/db_set/qb.update().set() instead of 1/0, or IfNull/Coalesce of a typed column with a different-typed literal such as IfNull(date_col, 0) -> COALESCE(date, integer) (PostgreSQL: 'COALESCE types date and integer cannot be matched'; the common IfNull(date,0) != 0 / == 0 presence test should be date_col.isnotnull() / .isnull(), else coalesce to a same-type default), or division by a possibly-zero divisor (Sum(a)/Sum(b) or x/col where the data can drive the divisor to 0 -- MariaDB returns NULL for division by zero but PostgreSQL raises 'division by zero' and aborts the query, so wrap the divisor in NullIf(divisor, 0)); or (2) would SILENTLY DIVERGE across engines: case-sensitive ==/.isin()/Strpos on USER-ENTERED free-text columns such as Data/Small Text/Long Text but NOT on Link/Select/name columns where exact-case matching is intended (PostgreSQL is case-sensitive, use Lower() both sides), lowercasing a value used as a document-name lookup, empty-string vs NULL in Concat/Concat_ws (MariaDB CONCAT(x,NULL) is NULL but PostgreSQL CONCAT drops the NULL, so a label like Concat('MFG-', nullable_date) leaks a bare 'MFG-' on PostgreSQL -- guard with Case/Coalesce/NullIf), NULL ordering (PostgreSQL sorts NULLs last) in ORDER BY..LIMIT 1, integer division (int/int truncates on PostgreSQL; multiply by 100.0 or make a literal a float, e.g. col/1440 -> col/1440.0), get_all(distinct=True, order_by=...) (frappe DROPS the ORDER BY for distinct queries on PostgreSQL, so sort in Python with key=str.casefold), an engine-specific function rewrite that does not match MariaDB on edge cases, or UnixTimestamp(date)/date-to-epoch math that is timezone-dependent (a strict epoch <= now bound is flaky on PostgreSQL). Also flag CATCH-AND-CONTINUE inserts: on PostgreSQL a failed insert aborts the WHOLE transaction (InFailedSqlTransaction), so code that swallows a duplicate/unique error and keeps going in the same transaction must wrap the fallible insert in frappe.db.savepoint(name) + rollback(save_point=name), unless it re-throws with no DB call before the throw or the insert uses ignore_if_duplicate=True or autoname='hash'. When RECOVERING the poisoned txn, prefer a SCOPED savepoint over a full frappe.db.rollback(): a full rollback discards rows the handler already created before the failure -- which MariaDB keeps -- so it is a silent MariaDB regression. 'The bg job / whitelist entrypoint owns the txn' does NOT make a full rollback safe if it did multiple inserts in a loop first; a full rollback is safe only when it immediately re-throws/raises, has nothing successful before it (single op), or the batch is meant to be atomic (a partial result is invalid -> rollback + mark Failed is correct). Otherwise use a per-iteration/per-record savepoint, and keep the function's success/None return contract (don't return a value for a doc that was just rolled back). GROUP BY ROW-COUNT TRAP (most important): to make a loose GROUP BY PostgreSQL-valid, do NOT add a non-functionally-dependent column (the classic traps are the child/row primary key or an editable per-row field) to GROUP BY because that splits one row into N and changes the MariaDB row count; Max()/Min()-wrap it instead (row count preserved, value arbitrary to deterministic). Judge functional dependence by the SOURCE TABLE: a column from a master joined on the group key is FD and safe in GROUP BY, but a descriptive field on the transaction table (e.g. t1.supplier_name, t1.territory) is NOT FD and must be wrapped. The SAME row-count trap applies to SELECT DISTINCT: to satisfy PostgreSQL's ORDER-BY-expr-must-appear-in-the-select rule, do NOT blindly add the ordered column to the select -- if it is not single-valued per existing distinct row the DISTINCT key grows and MariaDB returns MORE rows (a regression); add it only if functionally dependent on the existing select columns, otherwise drop the SQL ORDER BY and sort in Python (key=str.casefold). Do NOT suggest changing a Max()-wrapped column to Sum() to make a number more correct, that changes MariaDB's value. REFACTOR / CONVERSION FAITHFULNESS: a commit labeled a 'refactor' or a raw-frappe.db.sql->frappe.qb/ORM conversion is meant to preserve behaviour but easily does not, and the change slips past the static checker and a one-engine green run -- diff the WHERE/predicate, the JOIN/ON conditions and the resulting ROW SET, not just the SELECT shape. A conversion that silently widens or narrows the filter (e.g. a 'posting_datetime > X' bound gaining an OR (posting_datetime == X AND creation > args.creation) branch under a sql->qb refactor) changes the rows touched on BOTH engines and is a regression hiding under a refactor label; call it out and require a test even if it is a deliberate bug-fix. DO NOT FLAG these false positives: .like()/['like'] on a TEXT column (already ILIKE on PostgreSQL -- but DO flag it on a non-text/integer column, see above), raw ifnull/backticks/LOCATE/REGEXP/.regexp() inside frappe.db.sql (auto-translated by the framework -- but RLIKE/.rlike() is NOT translated, see above), or an ORDER BY..LIMIT 1 tie where adding a tiebreaker would change MariaDB's current pick. Full catalog with examples and portable fixes is in .github/POSTGRES_COMPATIBILITY.md.", "customContext": { "files": [ { diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e478347b8a..f6993ca1570 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,7 +48,6 @@ repos: cypress/.*| .*node_modules.*| .*boilerplate.*| - erpnext/public/js/controllers/.*| erpnext/templates/pages/order.js| erpnext/templates/includes/.* )$ diff --git a/banking/package.json b/banking/package.json index b46a7c4ff98..a957fb01bf6 100644 --- a/banking/package.json +++ b/banking/package.json @@ -14,35 +14,35 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@tailwindcss/vite": "^4.3.0", + "@tailwindcss/vite": "^4.3.2", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.24", - "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react": "^6.0.3", "chrono-node": "^2.9.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dayjs": "^1.11.20", - "frappe-react-sdk": "^1.15.0", + "frappe-react-sdk": "^1.17.0", "fuse.js": "^7.3.0", - "jotai": "^2.20.0", - "jotai-family": "^1.0.1", + "jotai": "^2.20.1", + "jotai-family": "^1.0.2", "lodash.isplainobject": "^4.0.6", "lucide-react": "^1.14.0", - "radix-ui": "^1.4.3", - "react": "^19.2.6", + "radix-ui": "^1.6.1", + "react": "^19.2.7", "react-currency-input-field": "^4.0.5", "react-day-picker": "9.14.0", - "react-dom": "^19.2.6", + "react-dom": "^19.2.7", "react-dropzone": "^15.0.0", "react-hook-form": "^7.75.0", "react-hotkeys-hook": "^5.3.2", "react-markdown": "^10.1.0", - "react-router": "^7.15.0", - "react-router-dom": "^7.15.0", + "react-router": "^8.1.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "safe-expr-eval": "^1.0.4", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.3.0", @@ -51,15 +51,15 @@ "vite": "^8.0.16" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^9.39.4", "@types/node": "^25.3.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.4.24", + "eslint-plugin-react-refresh": "^0.5.3", "globals": "^16.5.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.48.0" + "typescript-eslint": "^8.62.1" } } diff --git a/banking/src/App.tsx b/banking/src/App.tsx index b46c5ba4233..2b726dd1dea 100644 --- a/banking/src/App.tsx +++ b/banking/src/App.tsx @@ -1,5 +1,5 @@ import { lazy, useEffect } from 'react' -import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { BrowserRouter, Navigate, Route, Routes } from 'react-router' import { FrappeProvider } from 'frappe-react-sdk' import { Toaster } from '@/components/ui/sonner' import BankReconciliation from '@/pages/BankReconciliation' diff --git a/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx b/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx index dd248d31092..c26b9e9fb22 100644 --- a/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx +++ b/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx @@ -2,7 +2,6 @@ import { useAtomValue } from "jotai" import { MissingFiltersBanner } from "./MissingFiltersBanner" import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms" import { useCurrentCompany } from "@/hooks/useCurrentCompany" -import { Paragraph } from "@/components/ui/typography" import type { ColumnDef } from "@tanstack/react-table" import { useCallback, useMemo, useState } from "react" import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk" @@ -26,6 +25,7 @@ import { Form } from "@/components/ui/form" import { useForm } from "react-hook-form" import { DateField } from "@/components/ui/form-elements" import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty" +import MarkdownRenderer from "@/components/ui/markdown" const BankClearanceSummary = () => { const bankAccount = useAtomValue(selectedBankAccountAtom) @@ -203,14 +203,14 @@ const BankClearanceSummaryView = () => { [accountCurrency, bankAccount, companyID, mutate, onCopy], ) + const content = _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`${bankAccount?.account}`, `${formattedFromDate}`, `${formattedToDate}`]) + return
- - ${bankAccount?.account}`, `${formattedFromDate}`, `${formattedToDate}`]) - }} /> - + + +
{error && } diff --git a/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx b/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx index 17ef3314a1f..4e5ddb425e2 100644 --- a/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx +++ b/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx @@ -18,6 +18,7 @@ import { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Checkbox } from "@/components/ui/checkbox" import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react" +import { evaluateAmountFormula } from "@/lib/amountFormula" import { flt, formatCurrency } from "@/lib/numbers" import { cn } from "@/lib/utils" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" @@ -215,38 +216,13 @@ const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: Unreconci }) } else { - /** - * The debit and credit amounts can also be expressions - like "transaction_amount * 0.5" - * So we need to compute the value of the expression - * We can use the eval function to do this. But we need to expose certain variables to the expression. - * One of them is transaction_amount which is the unallocated amount of the selected transaction - * @param expression - The expression to compute - * @returns The computed value - */ - const computeExpression = (expression: string) => { - - const script = ` - const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0} - ${expression}; - ` - - let value = 0; - - try { - value = window.eval(script); - } catch (error: unknown) { - console.error(error); - value = 0; - } - - return value; - } + const transactionAmount = selectedTransaction.unallocated_amount ?? 0 if (!acc?.debit && !acc?.credit) { hasTotallyEmptyRowEarlier = true; } - const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0 - const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0 + const computedDebit = acc?.debit ? flt(evaluateAmountFormula(acc.debit, transactionAmount), 2) : 0 + const computedCredit = acc?.credit ? flt(evaluateAmountFormula(acc.credit, transactionAmount), 2) : 0 totalDebits = flt(totalDebits + computedDebit, 2) totalCredits = flt(totalCredits + computedCredit, 2) diff --git a/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx b/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx index 7b505efadc3..0815bc8a65e 100644 --- a/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx +++ b/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx @@ -2,7 +2,6 @@ import { useAtomValue } from "jotai" import { MissingFiltersBanner } from "./MissingFiltersBanner" import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms" import { useCurrentCompany } from "@/hooks/useCurrentCompany" -import { Paragraph } from "@/components/ui/typography" import { useCallback, useMemo } from "react" import type { ColumnDef } from "@tanstack/react-table" import { useFrappeGetCall } from "frappe-react-sdk" @@ -19,6 +18,7 @@ import _ from "@/lib/translate" import { toast } from "sonner" import { useCopyToClipboard } from "usehooks-ts" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" +import MarkdownRenderer from "@/components/ui/markdown" const BankReconciliationStatement = () => { const bankAccount = useAtomValue(selectedBankAccountAtom) @@ -189,14 +189,14 @@ const BankReconciliationStatementView = () => { return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry)) }, [data]) + const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`${bankAccount?.account}`, `${formatDate(dates.toDate)}`]) + return
- - ${bankAccount?.account}`, `${formatDate(dates.toDate)}`]) - }} /> - + + +
{error && } diff --git a/banking/src/components/features/BankReconciliation/BankTransactionList.tsx b/banking/src/components/features/BankReconciliation/BankTransactionList.tsx index 17f231a0833..1513e567a4b 100644 --- a/banking/src/components/features/BankReconciliation/BankTransactionList.tsx +++ b/banking/src/components/features/BankReconciliation/BankTransactionList.tsx @@ -1,7 +1,6 @@ import { useAtomValue, useSetAtom } from "jotai" import { MissingFiltersBanner } from "./MissingFiltersBanner" import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms" -import { Paragraph } from "@/components/ui/typography" import { formatDate } from "@/lib/date" import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view" import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers" @@ -23,6 +22,7 @@ import { useCallback, useMemo, useState } from "react" import { Link } from "react-router" import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription, EmptyContent } from "@/components/ui/empty" import { InputGroup, InputGroupAddon } from "@/components/ui/input-group" +import MarkdownRenderer from "@/components/ui/markdown" const BankTransactions = () => { const selectedBank = useAtomValue(selectedBankAccountAtom) @@ -243,14 +243,14 @@ const BankTransactionListView = () => { }, [data, search, amountFilter, typeFilter, status]) + const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`${bankAccount?.account_name}`, `${formattedFromDate}`, `${formattedToDate}`]) + return
- - ${bankAccount?.account_name}`, `${formattedFromDate}`, `${formattedToDate}`]) - }} /> - + + +