Compare commits

..

295 Commits

Author SHA1 Message Date
Rushabh Mehta
4611dd1c36 Merge pull request #55532 from rmehta/feat/build-and-upload-assets
feat: build and upload assets to GitHub Releases
2026-06-02 07:37:30 +05:30
Rushabh Mehta
6ac050e624 feat: build and upload assets to GitHub Releases 2026-06-02 06:45:10 +05:30
Diptanil Saha
71fcda5ab7 fix(pos): escape html output in pos page templates (#55527)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 00:43:08 +05:30
Diptanil Saha
86f6a8154d Revert "ci(crowdin): mapped zh-TW with zh_TW" (#55524) 2026-06-01 16:50:12 +00:00
Diptanil Saha
97ec7f8837 ci(crowdin): mapped zh-TW with zh_TW (#55520) 2026-06-01 16:21:20 +00:00
rohitwaghchaure
ba477412ea Merge pull request #55415 from aerele/fix/support-#68706
fix(stock): allow to create quality inspection after purchase/delivery
2026-06-01 21:43:24 +05:30
MochaMind
7b7732531f fix: sync translations from crowdin (#55464) 2026-06-01 21:38:59 +05:30
Diptanil Saha
56f89cc392 chore(serial_and_batch_bundle): remove update_serial_or_batch method (#55481) 2026-06-01 21:23:32 +05:30
Gajendra Nishad
57dbac712f fix(je): preserve account on duplicate row when party row exists (#55180) 2026-06-01 18:41:19 +05:30
Diptanil Saha
24b28b4d29 fix(pos): escape item data on pos item selector (#55503) 2026-06-01 16:57:35 +05:30
Nikhil Kothari
65b87ec045 fix(banking): miscellaneous bug fixes (#55492)
* fix(banking): correct usage of hooks in rule action

* fix(banking): apply ESLint rules for hooks

* fix(banking): add lazy imports and code-splitting
2026-06-01 10:36:48 +00:00
rohitwaghchaure
fbe6754b55 Merge pull request #55480 from mihir-kandoi/nonetype-manufacturing-error
fix: NoneType reference error in Stock Entry
2026-06-01 15:20:00 +05:30
ruthra kumar
ed43880a7d Merge pull request #55495 from ruthra-kumar/opening_bal_bug_in_process_pcv
fix: opening bal double counting in Process Period Closing Voucher
2026-06-01 15:01:36 +05:30
ruthra kumar
7f2af123ee test: prevent double counting of opening balances 2026-06-01 14:29:05 +05:30
Mihir Kandoi
8314c22aa6 fix: NoneType reference error in Stock Entry 2026-06-01 13:48:44 +05:30
ruthra kumar
cfeffbb354 refactor: color coded status in list view 2026-06-01 12:51:04 +05:30
Ankush Menat
dd1d2925d5 fix: check perm for account (#55479) 2026-06-01 07:04:38 +00:00
Mihir Kandoi
3deab36d2e fix: remove old subcontracting flow references in BOM (#55477) 2026-06-01 06:56:58 +00:00
ruthra kumar
1960c81619 refactor: tabbed view for process period closing voucher 2026-06-01 12:17:44 +05:30
ruthra kumar
a2b8334046 refactor: only consider non-opening balance for Balance sheet accounts 2026-06-01 12:13:49 +05:30
Mihir Kandoi
dbcfac839c chore: rename type field to secondary_item_type (#55469) 2026-06-01 05:54:59 +00:00
Mihir Kandoi
1c94c42b28 fix: pick correct name when creating user from RFQ (#55468) 2026-06-01 05:36:46 +00:00
Sudharsanan11
e003fe4de0 fix(stock): add warning message to notify the user to configure the inspection 2026-06-01 10:37:29 +05:30
Sudharsanan11
c6a88ab1d2 fix(stock): allow to create quality inspection after purchase/delivery 2026-06-01 10:37:25 +05:30
Diptanil Saha
45d9af9430 fix(pos): preserve contacts and enforce permissions in set_customer_info (#55463) 2026-06-01 04:30:01 +05:30
Khushi Rawat
32594c97c6 Merge pull request #55461 from khushi8112/supplier-master-form-cleanup
fix: supplier master form cleanup
2026-06-01 02:58:57 +05:30
khushi8112
515983e016 fix: supplier status in list view 2026-06-01 02:32:36 +05:30
khushi8112
820c0caf88 fix: supplier master form cleanup 2026-06-01 02:06:42 +05:30
Diptanil Saha
876f403500 fix(issue): check permission before issue status modification (#55458) 2026-05-31 22:07:28 +05:30
Diptanil Saha
a7e2daff7e fix(book_appointment): when scheduling is disabled, block API endpoints (#55455) 2026-05-31 15:31:44 +00:00
Diptanil Saha
0f2d9cea6a refactor: task_info portal pages (#55448) 2026-05-31 14:54:37 +00:00
MochaMind
2a39b95e2b chore: update POT file (#55452) 2026-05-31 15:06:32 +02:00
Diptanil Saha
925f39e819 refactor(pos_profile): migrating raw sql to qb in set_defaults (#55447) 2026-05-31 09:24:55 +00:00
MochaMind
be7df9d416 fix: sync translations from crowdin (#55427) 2026-05-31 12:43:17 +05:30
Sudharsanan Ashok
4ef17c9c1b fix(stock): change qb to qb get_query to fix filter issues (#55443) 2026-05-31 12:21:22 +05:30
Raffael Meyer
f2e7d90688 chore(Bank Statement Import): mark as out of beta (#55442) 2026-05-30 20:17:36 +00:00
Raffael Meyer
aed957e7d1 chore: mark as out of beta (#55439) 2026-05-30 18:58:05 +00:00
mh35
b8bb57cec9 fix(regional): Japanese CT Rate (#54998) 2026-05-30 15:33:49 +00:00
Diptanil Saha
9758eb868d fix(quotation): made customer contact column visible (#55433) 2026-05-30 18:38:11 +05:30
MochaMind
a4fd593e7d fix: sync translations from crowdin (#55361) 2026-05-29 23:04:14 +05:30
rohitwaghchaure
bfcedaf667 Merge pull request #55417 from rohitwaghchaure/fixed-support-69655
fix: billing address does not belongs to the company error
2026-05-29 22:53:16 +05:30
Diptanil Saha
3b44419a7f ci: configure upstream fetch refspec so git fetch creates tracking refs (#55422)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 16:16:43 +00:00
Diptanil Saha
1ae46b54b2 ci: split sync into orchestrator + per-branch runners, generalise for any app (#55414)
* ci: re-fetch before push to avoid force-push on translations_hotfix

If upstream/translations_hotfix moved forward while the script was
running (e.g., a concurrent run or manual push), git push would fail
with "behind remote". Re-fetch right before pushing and merge any new
remote commits with -X ours so our .po changes and the main.pot from
${HOTFIX_BRANCH} (set by the initial -X theirs merge) are preserved
without needing a force-push.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: define HOTFIX_BRANCH once as job env; pass it to sync script

version-16-hotfix is now declared as env.HOTFIX_BRANCH at the job level
so the checkout ref and the script argument both derive from the same
value. Quoting the GITHUB_WORKSPACE path guards against spaces on
self-hosted runners.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: define HOTFIX_BRANCH as job env and pass to sync script via env

Declares version-16-hotfix once as env.HOTFIX_BRANCH at the job level
so the checkout ref and the script both derive from the same value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: read HOTFIX_BRANCH from env instead of hardcoding in script

Removes the hardcoded branch name from the script; reads it from the
HOTFIX_BRANCH env var set by the workflow. Fails immediately with a
clear message if the var is not set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: use ls-remote to check branch existence instead of swallowing fetch errors

Replace `git fetch ... 2>/dev/null || true` + `git rev-parse --verify`
with `git ls-remote --exit-code --heads upstream translations_hotfix`.
ls-remote queries the remote directly so the check is never based on
stale local state, and real failures (auth, network) propagate through
set -e instead of being silently ignored. Applied to both the initial
branch setup and the pre-push re-fetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: rewrite orchestrator to dispatch runner per hotfix branch via matrix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: add per-branch runner workflow for hotfix translation sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: generalise sync script to use APP_NAME and GITHUB_REPOSITORY

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: remove push trigger from runner workflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: rename working branch to sync_translations_{hotfix_branch}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 20:13:45 +05:30
Rohit Waghchaure
9df07b367a fix: billing address does not belongs to the company error 2026-05-29 19:17:34 +05:30
Loic Oberle
618045ec98 refactor(sales_invoice): replace sql with qb in get_all_mode_of_payments (#55377) 2026-05-29 17:34:20 +05:30
Loic Oberle
94828e743d refactor(sales_invoice): replace sql with qb in update_billing_status… (#55380) 2026-05-29 17:30:41 +05:30
Khushi Rawat
46f4f79889 Merge pull request #55341 from khushi8112/customer-master-from-cleanup
fix: customer master form cleanup
2026-05-29 16:29:06 +05:30
khushi8112
059f560017 fix: add customer type in the list view 2026-05-29 15:33:50 +05:30
Khushi Rawat
24fabe6893 Merge pull request #55397 from khushi8112/item-master-list-view
fix: item master list view UI cleanup
2026-05-29 15:02:11 +05:30
Diptanil Saha
621c1c595a ci: fix branch base and per-language commits in sync-hotfix-translations (#55405)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:48:03 +05:30
Loic Oberle
eb638d8f3a refactor(sales_invoice): Replace SQL with orm in get_company_abbr (#55384) 2026-05-29 13:58:51 +05:30
Loic Oberle
9b1229f4cd refactor(sales_invoice): replace sql with orm in clear_unallocated_mo… (#55383) 2026-05-29 13:58:10 +05:30
Loic Oberle
d4f8c033fc refactor(account): Replace the SQL queries with qb and the frappe ORM (#55396) 2026-05-29 13:33:02 +05:30
Loic Oberle
3e9c4aefaf refactor(sales_invoice): replace sql with qb in validate_proj_cust (#55382) 2026-05-29 13:30:45 +05:30
Loic Oberle
f846c55c01 refactor(sales_invoice): replace sql with qb in get_warehouse (#55381) 2026-05-29 13:29:10 +05:30
Nishka Gosalia
03a7e5b6a3 Merge pull request #55400 from nishkagosalia/gh-55292
fix: Make Distributed Discount Amount field read only
2026-05-29 12:59:31 +05:30
Nishka Gosalia
2e97f36f61 Merge pull request #55399 from nishkagosalia/gh-55104-fix
fix: over order allowance setting fix
2026-05-29 12:37:27 +05:30
nishkagosalia
512c95529e fix: Make Distributed Discount Amount field read only 2026-05-29 12:24:47 +05:30
nishkagosalia
30011963bc fix: over order allowance setting fix 2026-05-29 12:09:19 +05:30
MochaMind
5d9ec20dff chore: update POT file (#55352) 2026-05-29 11:01:44 +05:30
khushi8112
69ee7e93d8 fix: item master list view UI cleanup 2026-05-29 02:25:38 +05:30
Khushi Rawat
fd7a97f424 Merge pull request #55385 from frappe/revert-55360-validate-pe-cancel-on-bank-reconciliation
Revert "fix: block cancellation if reconciled with a Bank Transaction"
2026-05-28 17:45:54 +05:30
rohitwaghchaure
15d71ccc0b Merge pull request #55302 from rohitwaghchaure/fixed-stock-entry-bom-issue
fix: 'NoneType' object has no attribute 'material_transferred_for_manufacturing'
2026-05-28 17:18:56 +05:30
rohitwaghchaure
feee40b30a Merge pull request #55323 from aerele/fix/support-#68170
fix(stock): change valuation rate column label in stock ledger entry/report
2026-05-28 16:46:09 +05:30
archielister
e7c695e0ac fix(stock): get_actual_qty during cancellations (#55388) 2026-05-28 16:45:50 +05:30
Rohit Waghchaure
f4516a2a7c fix: 'NoneType' object has no attribute 'material_transferred_for_manufacturing' 2026-05-28 16:45:32 +05:30
Loic Oberle
e1bfffb72c refactor(sales_invoice): replace sql with qb in get_discounting_status (#55378) 2026-05-28 16:43:17 +05:30
Loic Oberle
ead0c14a12 refactor(sales_invoice): replace sql with qb in check_if_reutrn_invoi… (#55374) 2026-05-28 16:36:46 +05:30
Khushi Rawat
75e9cd9e8f Revert "fix: block cancellation if reconciled with a Bank Transaction" 2026-05-28 15:19:42 +05:30
Nishka Gosalia
774756c3f4 Merge pull request #55367 from nishkagosalia/gh-55050
fix(UX): Move title field to More Info
2026-05-28 15:08:54 +05:30
Mihir Kandoi
10384b3b2e fix: new bom version should not recalculate operations through routing (#55370) 2026-05-28 09:29:37 +00:00
nishkagosalia
34c24b86fa fix(UX): Move title field to More Info 2026-05-28 14:38:08 +05:30
Nishka Gosalia
acb10299db Merge pull request #55340 from nishkagosalia/gh-55106
feat: over order allowance setting
2026-05-28 14:18:33 +05:30
nishkagosalia
355d71dbd2 feat: over order allowance setting 2026-05-28 12:54:45 +05:30
Khushi Rawat
49567bff78 Merge pull request #55360 from khushi8112/validate-pe-cancel-on-bank-reconciliation
fix: block cancellation if reconciled with a Bank Transaction
2026-05-28 11:26:22 +05:30
khushi8112
63ff92cb7c fix: test case 2026-05-28 01:49:56 +05:30
khushi8112
6f5852eabf fix: block cancellation if reconciled with a Bank Transaction 2026-05-28 01:27:05 +05:30
Khushi Rawat
c90a33cba1 Merge pull request #55137 from khushi8112/sales-analytics-report
fix: use get_query instead of get_all for data fetching
2026-05-28 00:45:51 +05:30
Diptanil Saha
fcb87b437e ci: add node setup on sync translations to version 16 hotfix (#55355) 2026-05-27 23:34:10 +05:30
Diptanil Saha
7561ad4666 ci: sync translations from develop to version-16-hotfix (#55348)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:50:03 +05:30
Diptanil Saha
1b076d0ccc fix: render HTML labels in open payment requests link dropdown (#55315) 2026-05-27 20:09:28 +05:30
khushi8112
6f6e17188f fix: customer master form cleanup 2026-05-27 17:07:46 +05:30
Lakshit Jain
1bcc214367 Merge pull request #55330 from ljain112/fix-tds-none
fix(tds): treat NULL and empty-string tax_withholding_group as equivalent
2026-05-27 16:54:03 +05:30
Lakshit Jain
dee4e94576 Merge pull request #55333 from ljain112/fix-financial-template-closing-bal
fix(custom_financial_template): sum account closing balances across dimensions
2026-05-27 16:53:34 +05:30
ljain112
4a49a205b3 fix(custom_financial_template): sum account closing balances across dimensions 2026-05-27 13:57:56 +05:30
ljain112
251e7b623c fix: changes as per review 2026-05-27 13:06:07 +05:30
ljain112
a85f8a64b1 fix(tds): treat NULL and empty-string tax_withholding_group as equivalent 2026-05-27 12:40:57 +05:30
Pandiyan P
05a46ffefd fix(selling): handle None values while grouping opportunities by utm … (#55300) 2026-05-27 06:52:43 +00:00
Sudharsanan11
373696d470 fix(stock): set outgoing rate as zero for inward transactions 2026-05-27 12:06:21 +05:30
Sudharsanan Ashok
3ad67021d6 fix(manufacturing): allow to edit batch size while creating a work order (#55058) 2026-05-27 05:57:17 +00:00
Sudharsanan11
4e7aa499ea fix(stock): change valuation rate column label in stock ledger entry/report 2026-05-27 11:00:47 +05:30
MochaMind
8bb611dfee fix: sync translations from crowdin (#55239) 2026-05-27 05:28:39 +05:30
Himanshu Jain
652014700c fix(employee): js error if user does not have write permission for date field (#55312) 2026-05-27 01:49:16 +05:30
Diptanil Saha
2a7867511d fix(sales_invoice): skip stock update for POS invoices linked to Delivery Note (#55311) 2026-05-26 20:13:40 +00:00
rohitwaghchaure
e1ddc50872 Merge pull request #55242 from rohitwaghchaure/fixed-stock-reco-for-legacy-serial-nos
fix: stock reco for legacy serial nos
2026-05-26 15:58:16 +05:30
rohitwaghchaure
5057057f43 Merge pull request #55290 from rohitwaghchaure/fixed-tax-amount-issue-in-invoice-lcv
fix: inclusive tax amount not considered while setting LCV from purchase invoice
2026-05-26 15:44:26 +05:30
Nihantra C. Patel
cad4d497bd Merge pull request #55268 from Nihantra-Patel/immutable-ledger-reverse-posting-date
fix: use passed posting date for period closing validation in reverse GL entries
2026-05-26 15:43:06 +05:30
Rohit Waghchaure
048ddfc265 fix: inclusive tax amount not considered while setting LCV from purchase invoice 2026-05-26 15:13:05 +05:30
Nihantra Patel
9c39b01f1c test: update testcase 2026-05-26 14:19:04 +05:30
Loic Oberle
a051049710 refactor(sales_order): Replace SQL with ORM in validate_po (#55198) 2026-05-26 08:20:04 +00:00
Mihir Kandoi
f023bf8a96 fix: single variant creation error (#55286)
* fix: single variant creation error

* feat: allow creation of any number of variants in multiple item variant creation dialog
2026-05-26 13:34:44 +05:30
Loic Oberle
b8327e4031 refactor(customer): replace SQL with query builder in get_customer_ou… (#55209) 2026-05-26 08:00:23 +00:00
Loic Oberle
bbb7b6f8e0 refactor(buying): replace sql query by orm (#55153) 2026-05-26 13:14:39 +05:30
Mihir Kandoi
090c25d848 feat: allow creation of any number of variants in multiple item variant creation dialog 2026-05-26 13:09:14 +05:30
Mihir Kandoi
bda75135c3 fix: single variant creation error 2026-05-26 12:53:47 +05:30
Mihir Kandoi
a128d851c5 refactor: remove unused customer field in Item DocType (#55277) 2026-05-26 05:17:00 +00:00
ruthra kumar
cd35fbde94 Merge pull request #55256 from ruthra-kumar/handle_stuck_running_state_in_process_pcv
refactor: handle processes stuck in running state in process pcv
2026-05-26 10:27:28 +05:30
Pandiyan P
c286a73e0b fix: prevent AttributeError in batch query filters (#55257) 2026-05-26 10:23:18 +05:30
ruthra kumar
6cb7971342 refactor: atomic summarization step for process pcv 2026-05-26 09:55:38 +05:30
diptanilsaha
49d579a016 fix(payment_entry): sync paid/received amounts for cross-currency entries (#55270) 2026-05-25 23:16:33 +05:30
Nihantra Patel
f040bdf165 fix: use passed posting date in make_reverse_gl_entries 2026-05-25 21:39:54 +05:30
Rohit Waghchaure
9d5fd11bcd fix: stock reco for legacy serial nos 2026-05-25 17:22:08 +05:30
rohitwaghchaure
af26986def Merge pull request #55252 from rohitwaghchaure/fixed-job-card-buttons-class
fix: job card buttons color
2026-05-25 17:21:34 +05:30
rohitwaghchaure
7982ecfdf7 Merge pull request #55249 from aerele/fix/support-#68708
fix(stock): remove precision for valuation rate while creating sle
2026-05-25 17:21:01 +05:30
ruthra kumar
f414778486 refactor: handle processes stuck in running state in process pcv 2026-05-25 16:02:35 +05:30
Khushi Rawat
631a4a67ba Merge pull request #55126 from khushi8112/asset-scrap-flow
fix: asset scrap flow related changes
2026-05-25 15:45:03 +05:30
Rohit Waghchaure
c327a5ca93 fix: job card buttons color 2026-05-25 15:33:12 +05:30
Sudharsanan11
66ba7be239 fix(stock): remove precision for valuation rate while calculating difference amount 2026-05-25 14:43:39 +05:30
Sudharsanan11
ccb8837c6c fix(stock): remove precision for valuation rate while creating sle 2026-05-25 14:42:58 +05:30
ruthra kumar
1c3a9f7dd9 refactor: summarize in background 2026-05-25 14:11:46 +05:30
Mihir Kandoi
bafa6f9508 feat: add party groups functionality to party specific item (#54988) 2026-05-25 12:06:54 +05:30
rohitwaghchaure
b0a83f9b22 Merge pull request #55216 from rohitwaghchaure/fixed-valuation-rate-for-fg-item-new
fix: fg valuation rate in repack entry when multiple FGs
2026-05-25 11:44:02 +05:30
MochaMind
7ae6535be9 chore: update POT file (#55235) 2026-05-24 14:47:38 +02:00
Mihir Kandoi
2a01a37d5d refactor: stock ageing report (#55231) 2026-05-24 15:43:07 +05:30
Mihir Kandoi
c1a4c3d053 refactor: use frappe.db.bulk_update instead of Case queries in subcon… (#55232) 2026-05-24 09:36:45 +00:00
Mihir Kandoi
004818e0ac fix: consider batchwise valuation in stock ageing report (#54919) 2026-05-24 12:27:48 +05:30
Mihir Kandoi
268910467a fix: not able to reserve product bundle through dialog (#55194) 2026-05-24 12:26:48 +05:30
Rohit Waghchaure
a47e4c04f7 fix: fg valuation rate in repack entry when multiple FGs 2026-05-23 14:42:38 +05:30
Loic Oberle
983ae011f0 refactor(sales_order): Replace SQL with ORM in make_maintenance_schedule (#55206) 2026-05-23 06:27:27 +00:00
Loic Oberle
6f9f6d3b7d refactor(sales_order): Replace SQL with ORM in validate_proj_cust (#55202) 2026-05-23 06:26:25 +00:00
Loic Oberle
9546374ac3 refactor(sales_order): Replace SQL with ORM in validate_sales_mntc_qu… (#55201) 2026-05-23 06:17:00 +00:00
Loic Oberle
78894f7c78 refactor(sales_order): Replace SQL with ORM in validate_for_items (#55199) 2026-05-23 06:13:50 +00:00
Loic Oberle
2d2b45f270 refactor(sales_order): Replace SQL with ORM in check_modified_date (#55205) 2026-05-23 11:40:04 +05:30
Loic Oberle
3cd9943cc0 refactor(customer): replace SQL with ORM in on_trash (#55211) 2026-05-23 11:39:28 +05:30
Loic Oberle
f9d430c4aa refactor(supplier_scorecard): replace sql with orm (#55169) 2026-05-23 11:37:09 +05:30
Loic Oberle
ea2eb3dc01 refactor(supplier_scorecard_variable): replace sql with query builder (#55162) 2026-05-23 11:36:11 +05:30
Loic Oberle
f370404a75 refactor(sales_order): Replace SQL with ORM in product_bundle_has_sto… (#55200) 2026-05-23 11:35:46 +05:30
Loic Oberle
4719ba15c6 refactor(sales_order): Replace SQL with query builder in make_mainten… (#55207) 2026-05-23 11:34:54 +05:30
Loic Oberle
e27b88d789 refactor(sales_order): replace SQL with ORM in check_nextdoc_docstatus (#55204) 2026-05-23 11:34:18 +05:30
Loic Oberle
f1c2d2e21d refactor(sales_order): Replace SQL with ORM in update_enquiry_status (#55203) 2026-05-23 11:33:54 +05:30
Loic Oberle
9a46b3374f refactor(sales_order):Replace SQL with query builder in get_events (#55208) 2026-05-23 11:33:23 +05:30
Loic Oberle
df3d0859a1 refactor(sales_person_wise_transaction_summary): Replace SQL with que… (#55191) 2026-05-23 11:29:09 +05:30
Loic Oberle
de531ceeb9 refactor(sales_person_wise_transaction_summary): Replace SQL with ORM (#55190) 2026-05-23 11:28:42 +05:30
Loic Oberle
c9593d8c62 refactor(customer): Replace SQL with query builder in get_customer_name (#55210) 2026-05-23 11:28:06 +05:30
Loic Oberle
4d0ee719c0 refactor(purchase_order): Use the ORM instead of SQL (#55173) 2026-05-23 11:26:23 +05:30
MochaMind
3aaa828e32 fix: sync translations from crowdin (#55118)
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
2026-05-22 22:47:48 +00:00
Nishka Gosalia
264c10dee8 Merge pull request #55189 from aerele/support-#69032
fix(project): update customer and sales order as no copy
2026-05-22 17:49:58 +05:30
Loic Oberle
98c2ec528c refactor(territory_wise_sales): replace sql with query builder (#55175) 2026-05-22 11:56:10 +00:00
Loic Oberle
e11e386fff refactor(territory_wise_sales):replace sql with query builder (#55174) 2026-05-22 11:45:56 +00:00
nareshkannasln
9d8f3863f2 fix(project): update customer and sales order as no copy 2026-05-22 16:47:46 +05:30
Mihir Kandoi
b71eacd6b3 fix: invalid filter on item_group (#55186) 2026-05-22 16:44:09 +05:30
Loic Oberle
8fb962e50e refactor(supplier_scorecard_variable):replace sql with query builder (#55168) 2026-05-22 10:44:30 +00:00
Loic Oberle
1b23ef2ff4 refactor(request_for_quotation): use query builder instead of SQL (#55172) 2026-05-22 16:11:48 +05:30
Loic Oberle
f5899b5519 refactor(supplier_scorecard):replace sql with orm (#55161) 2026-05-22 16:11:04 +05:30
Loic Oberle
30ba93fb8f refactor(supplier_quotation): Replace SQL by the orm (#55155) 2026-05-22 16:10:40 +05:30
Loic Oberle
e7c4fb85f8 refactor(request_for_quotation): Use query builder instead of SQL (#55171) 2026-05-22 16:10:18 +05:30
Loic Oberle
1135429181 refactor(territory_wise_sales):replace sql with orm (#55177) 2026-05-22 10:39:41 +00:00
Loic Oberle
f6bf7d85ad refactor(supplier_qotation): Replace sql by query builder (#55154) 2026-05-22 16:09:15 +05:30
Loic Oberle
ab99c9a54e refactor(supplier_scorecard): Replace sql with orm (#55170) 2026-05-22 16:07:12 +05:30
Loic Oberle
e75de4d337 refactor(supplier_scorecard_variable): replace sql with query builder (#55167) 2026-05-22 16:06:30 +05:30
Loic Oberle
2eb2defd90 refactor(supplier_scorecard_variable): replace sql with query builder (#55163) 2026-05-22 16:06:04 +05:30
Loic Oberle
82d19677ed refactor(supplier_scorecard_variable): replace sql with query builder (#55164) 2026-05-22 16:05:33 +05:30
Loic Oberle
b84ec2d22a refactor(territory_wise_sales): replace SQL with query builder (#55176) 2026-05-22 16:04:18 +05:30
rohitwaghchaure
719cf8a48f Merge pull request #55091 from rohitwaghchaure/fixed-job-card-pending-qty
feat: pending qty in job card
2026-05-22 15:21:44 +05:30
Loic Oberle
1bc8d02cef refactor(queries): migrate item_query to Query Builder (#54834)
* refactor(queries): migrate item_query to Query Builder

Use Frappe Query Builder to ensure compatibility with PostgreSQL.
The implementation still relies on raw SQL for fcond and mcond through
LiteralValue to maintain compatibility with legacy filter builders.

* refactor(queries): migrate item_query to Query Builder

Fix the bugs found by coderabbit.
For the eol condition: PostgreSQL raises DatetimeFieldOverflow when evaluating '0000-00-00' as
a date literal, even inside NULLIF(). Added a db_type guard to skip the
zero-date condition on PostgreSQL, where it can never be stored anyway.

No generic cross-db solution found for this case; open to revisiting

* refactor(queries): Rework item_query to use get_query

Rework the item_query method to use get_query with the ignore_permissions flag at False

* refactor(controller): Fix the query builder

Fix the build query in item_query according to coderabbit

* refactor(queries): explicitly add has_variants

Explicitely add has_variants==0 to the query according to coderabbit feedback
2026-05-22 09:42:06 +00:00
rohitwaghchaure
8915095804 Merge pull request #55159 from rohitwaghchaure/fixed-slow-query
fix: slow query
2026-05-22 14:46:19 +05:30
Nishka Gosalia
ace4e45cfe fix: edit stock uom qty for purchase documents (#55135) 2026-05-22 14:23:24 +05:30
Nihantra C. Patel
9eeccecd30 perf: skip delink_original_entry during cancellation when Immutable Ledger is enabled (#55130)
* perf: get payment ledger and remove update from delink when immutable ledger is enabled

* revert: changes of get_payment_ledger_entries

* perf: skip delink_original_entry during cancellation when Immutable Ledger is enabled

* test: for immutable ledger

* test: add posting_date in create_sales_invoice

* fix: link validation err with immutable ledger on

* test: update testcase of the immutable ledger

* refactor(test): simpler test for immutable invariants

---------

Co-authored-by: ruthra kumar <ruthra@erpnext.com>
2026-05-22 12:32:53 +05:30
Rohit Waghchaure
d44f574581 fix: slow query 2026-05-22 11:41:13 +05:30
rohitwaghchaure
ebcdcfcd84 Merge pull request #53679 from aerele/feat/SDBNB-account
feat: add Stock Delivered But Not Billed (SDBNB) accounting for DN and SI
2026-05-22 08:41:39 +05:30
kavin-114
91026fbdb3 fix: classify Stock Delivered But Not Billed as a Current Asset
This account holds a debit balance (inventory value delivered but not yet
invoiced) and clears to COGS on Sales Invoice, so it is economically a
short-term clearing asset rather than a trade payable. Move it from the
Stock Liabilities group to Stock Assets under Current Assets, with
account_category "Stock Assets" (and account_number 1420 in the numbered
chart). The account_type "Stock Delivered But Not Billed" is unchanged,
so posting logic in Sales Invoice and Delivery Note continues to key off
the correct account.
2026-05-22 06:50:23 +05:30
rohitwaghchaure
61547fff44 chore: fixed test case 2026-05-22 06:50:23 +05:30
Rohit Waghchaure
ba1f40fdd9 fix: posting date and time 2026-05-22 06:50:23 +05:30
Pugazhendhi Velu
9ff3e28f5d fix: validate expense account for items linked to sales invoice 2026-05-22 06:50:23 +05:30
kavin-114
78993c1ebe fix: update cost center tests to use dynamic expense account
Existing tests hardcoded "Cost of Goods Sold" as expected GL account,
but SDBNB overrides it on DN submission. Use dn.items[0].expense_account
to work with both SDBNB-enabled and legacy companies.
2026-05-22 06:50:23 +05:30
kavin-114
6ee7dc0b49 test: add unit test cases for Stock Delivered But Not Billed 2026-05-22 06:50:23 +05:30
kavin-114
05877140d1 feat: handle post delivery invoices gl reposting 2026-05-22 01:13:12 +05:30
Pugazhendhi Velu
3364ee9274 feat(stock): add Stock Delivered But Not Billed GL entries on Delivery Note and Sales Invoice 2026-05-22 01:13:12 +05:30
Pugazhendhi Velu
8596d98ac4 feat(accounts): add Stock Delivered But Not Billed account type and defaults 2026-05-22 01:13:12 +05:30
Pugazhendhi Velu
bb5d4d8682 feat(company): add Stock Delivered But Not Billed account configuration 2026-05-22 01:13:12 +05:30
Khushi Rawat
8ea7efc01d Merge pull request #55146 from khushi8112/payment-entry-foreign-currency-remarks
fix: correct remarks for foreign currency payment entries
2026-05-21 20:11:38 +05:30
Khushi Rawat
23b5afc5de Merge pull request #54946 from Shllokkk/letter-head-fix
feat(company): add a default_letter_head_report field in company doctype
2026-05-21 20:05:56 +05:30
rohitwaghchaure
160b92f9cd Merge pull request #54466 from rohitwaghchaure/revamp-stock-entry
refactor: stock_entry file to improve readability and maintainability
2026-05-21 19:47:04 +05:30
Rohit Waghchaure
1be92f6d05 refactor: better timer and complete button 2026-05-21 19:45:10 +05:30
Khushi Rawat
70b9f549a4 Merge pull request #55147 from khushi8112/debit-note-rate-adjustment-description
fix: correct description for Is Rate Adjustment Entry (Debit Note) checkbox
2026-05-21 18:06:51 +05:30
Rohit Waghchaure
0a215b0717 refactor: job_card.js code for better readability 2026-05-21 17:46:29 +05:30
Rohit Waghchaure
db64f451c1 feat: pending qty in job card 2026-05-21 17:46:24 +05:30
khushi8112
92c969478e fix: correct description for Is Rate Adjustment Entry (Debit Note) checkbox 2026-05-21 17:33:59 +05:30
khushi8112
c6cde700b5 fix: correct remarks for foreign currency payment entries 2026-05-21 17:25:55 +05:30
Rohit Waghchaure
068f7b9a8d refactor: split large functions into smaller functions 2026-05-21 17:12:59 +05:30
Khushi Rawat
83f100bae1 Merge pull request #55142 from khushi8112/composite-asset-net-purchase-amount-reset
fix: don't reset net_purchase_amount for Composite Asset if already set
2026-05-21 17:07:41 +05:30
khushi8112
98dae6e43a fix: don't reset net_purchase_amount for Composite Asset if already set 2026-05-21 17:04:33 +05:30
diptanilsaha
18bdd0afd3 Merge pull request #55127 from diptanilsaha/fix/tax-rule-date-filter
refactor: migrate get_tax_template to query builder with hierarchical group matching
2026-05-21 17:04:02 +05:30
diptanilsaha
8c43118725 test: add tests for supplier group hierarchy and use_for_shopping_cart filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:43:00 +05:30
diptanilsaha
4d43c74f5f fix: default use_for_shopping_cart to 0 in set_taxes
Ensures regular transactions only match tax rules where
use_for_shopping_cart = 0, preventing webshop-specific rules
from applying to standard documents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:43:00 +05:30
diptanilsaha
f98975f51a refactor: rewrite get_tax_template using query builder
Migrates from raw frappe.db.sql with string interpolation to frappe.qb.
Adds hierarchical supplier_group matching (mirrors customer_group behaviour).
Removes unused get_customer_group_condition helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:43:00 +05:30
diptanilsaha
cb610b79d2 feat: add get_parent_supplier_groups using query builder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:42:56 +05:30
diptanilsaha
91a2a7b0a0 refactor: migrate get_parent_customer_groups to query builder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:41:53 +05:30
rohitwaghchaure
8aaa7c0993 Merge pull request #55134 from rohitwaghchaure/fixed-removed-redundant-code
fix: removed redundant code
2026-05-21 15:24:41 +05:30
khushi8112
1fd99337b3 fix: use get_query instead of get_all for data fetching 2026-05-21 15:05:44 +05:30
Pandiyan P
1a81265c2c fix(manufacturing): remove forecast_qty and adjust_qty fields from sa… (#55129) 2026-05-21 15:01:55 +05:30
Rohit Waghchaure
14b17cd8a6 fix: removed redundant code 2026-05-21 14:56:35 +05:30
Mihir Kandoi
2f35660142 fix: consumed operation cost calculation (#54858) 2026-05-21 14:55:46 +05:30
khushi8112
21bb8fe979 fix: asset scrap flow related changes 2026-05-21 12:14:08 +05:30
Jatin3128
06477119d1 fix: corrected the pricing rule taking the wrong value (#54894) 2026-05-21 12:04:45 +05:30
Rohit Waghchaure
961cbc3625 refactor: using agentic AI 2026-05-21 09:52:55 +05:30
Raffael Meyer
341891e326 fix: status for settled credit notes in sales invoice list (#54764) 2026-05-20 21:50:41 +02:00
Rohit Waghchaure
4d14727b26 fix: linter issue 2026-05-20 23:31:09 +05:30
Mihir Kandoi
33dc1f5f09 fix: set weight in update items (#55089) 2026-05-20 16:38:37 +00:00
Rohit Waghchaure
a3a7733440 test: fixed test cases 2026-05-20 21:59:17 +05:30
Daniel Radl
d85f6a4541 chore: migrate to new docker publish workflow (#54499) 2026-05-20 16:22:09 +00:00
Raffael Meyer
8845be9419 fix: allow direct drop-ship on Purchase Orders without Sales Order (#54930) 2026-05-20 18:03:21 +02:00
Mihir Kandoi
3084e3654c fix: item price with party condition (#55100) 2026-05-20 11:48:15 +00:00
Mihir Kandoi
00057b1798 fix: valuation rate missing for standalone credit notes for moving av… (#55102) 2026-05-20 11:28:01 +00:00
Mihir Kandoi
0bbddf4994 fix: set bin details when adding item using update items (#55096) 2026-05-20 09:46:05 +00:00
Shllokkk
bd84434d34 fix: incorrect error message string in sales order (#55090) 2026-05-20 14:41:06 +05:30
Pandiyan P
a3950590da fix(manufacturing): fetch from_bom name in production plan (#55085) 2026-05-20 14:22:17 +05:30
diptanilsaha
6c6fa722af chore: migrate Address/Contact custom fields from JSON fixtures to install (#55084)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 08:39:24 +00:00
MochaMind
eb67afa01a fix: sync translations from crowdin (#55065) 2026-05-20 13:53:53 +05:30
diptanilsaha
12bb86d688 chore: remove frappe-semgrep-rules submodule (#55083) 2026-05-20 07:28:01 +00:00
Rohit Waghchaure
38eeb6994c test: fixed test cases 2026-05-20 12:02:44 +05:30
ruthra kumar
dd782d96bf Merge pull request #55072 from ruthra-kumar/faster_opening_balance_range_calculation
perf: faster opening balance range calculation in process period closing voucher
2026-05-20 11:48:10 +05:30
Sudharsanan Ashok
b9e08f3ce4 fix(stock): remove recalculate current qty function (#54774) 2026-05-20 11:37:26 +05:30
ruthra kumar
eba58b2837 refactor: ppcv select with for update and skip locked 2026-05-20 11:23:06 +05:30
ruthra kumar
ee33574a6d fix: faster range calculation on process period closing voucher 2026-05-20 11:23:00 +05:30
MochaMind
202ea0061c fix: sync translations from crowdin (#54951) 2026-05-20 00:50:45 +05:30
Nabin Hait
13e0a211ae fix: prevent negative amounts in common party JE on return invoices (#55034)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 00:48:29 +05:30
Nabin Hait
87a4e872cf fix: use route_options for Credit Note and Debit Note sidebar links (#55026)
fix: use route_options instead of filters for Credit Note and Debit Note sidebar links

Filters with ["=", value] format produce broken URLs like
`?is_return=%3D%2C1` instead of `?is_return=1`. Switching to
route_options with a plain JSON object generates correct URLs.
2026-05-19 23:13:30 +05:30
Nabin Hait
fa403dd23b fix: warn when accounting dimension fieldname conflicts with existing fields (#55036) 2026-05-19 23:04:47 +05:30
Nabin Hait
55bb6e0357 fix: handle None delivery_date when sorting MPS data (#55028) 2026-05-19 21:08:47 +05:30
Nabin Hait
6114293b92 chore: remove leaderboard dead code (#55030) 2026-05-19 21:07:52 +05:30
Rohit Waghchaure
e4b5e6bd1e refactor: split stock_entry.py into multiple files for better readability 2026-05-19 18:41:31 +05:30
ruthra kumar
83cba39aa7 Merge pull request #55053 from ruthra-kumar/drop_procedures_first_and_then_change
fix(patch): drop dead procedures first before other changes
2026-05-19 16:37:40 +05:30
Ravibharathi
ad7ddae32f fix: validate company region in uae vat 201 (#54899) 2026-05-19 16:30:07 +05:30
ruthra kumar
61d24ba55f fix(patch): drop dead procedures first before other changes 2026-05-19 16:12:25 +05:30
rohitwaghchaure
6878fc9ab6 Merge pull request #55046 from rohitwaghchaure/fixed-incorrect-balance
fix: stock balance showing incorrect value because of incorrect SLE
2026-05-19 13:50:55 +05:30
Rohit Waghchaure
94b95d6c2f fix: stock balance showing incorrect value because of incorrect SLE 2026-05-19 13:23:32 +05:30
Ravibharathi
133ccd8214 Merge pull request #54761 from aerele/fix-validate-due-date-with-template
fix: normalize date comparison to avoid datatype mismatch
2026-05-19 11:26:55 +05:30
Nabin Hait
f99e331742 fix: prevent duplicate task execution and timestamp error in transaction deletion (#55021) 2026-05-18 23:06:09 +05:30
Sudharsanan Ashok
21a9eedb5c fix(stock): update buying amount calculation in gross profit report (#55020) 2026-05-18 22:33:10 +05:30
ruthra kumar
eac31d2ab4 Merge pull request #55001 from ruthra-kumar/remove_ar_procedures
fix: remove sql procedure method from AR report
2026-05-18 13:44:18 +05:30
ruthra kumar
63a7142b9b fix: remove sql procedure method from AR report 2026-05-18 12:16:46 +05:30
Nishka Gosalia
ae9c632e39 fix: toast message for item price insert (#55009) 2026-05-18 06:10:40 +00:00
Soham Kulkarni
26f5f110d6 Merge pull request #55000 from sokumon/workspace-json
fix: remove parent page
2026-05-18 10:31:06 +05:30
sokumon
e13bd9eaa6 fix: remove parent page 2026-05-18 10:09:28 +05:30
MochaMind
78e3b54953 chore: update POT file (#54991) 2026-05-17 21:45:21 +02:00
Sudharsanan Ashok
2ad9231fb2 fix(stock): apply posting datetime filters while fetching available batches (#54976) 2026-05-17 06:43:13 +00:00
rohitwaghchaure
7d1a86f4e5 Merge pull request #54962 from rohitwaghchaure/fixed-legacy-serial-no
fix: incoming rate for legacy serial no
2026-05-15 22:09:24 +05:30
Ejaaz Khan
55d6bc475e Merge pull request #54655 from iamejaaz/remove-sales-print
refactor: remove dead print format
2026-05-15 17:26:48 +05:30
diptanilsaha
712403aae4 Merge pull request #54963 from diptanilsaha/fix/pe_paid_amt_rec_amt
fix(payment_entry): fix paid/received amount calculation for multi-currency accounts
2026-05-15 15:42:46 +05:30
Rohit Waghchaure
2773b7c002 fix: incoming rate for legacy serial no 2026-05-15 15:21:36 +05:30
diptanilsaha
69642860ee fix(payment_entry): paid_amount and received_amount calculation depending upon account_currency 2026-05-15 14:31:37 +05:30
rohitwaghchaure
d22cd7b856 Merge pull request #54403 from aerele/fix/support-#64052
fix(stock): ignore fetching warehouse account for asset items
2026-05-15 13:15:11 +05:30
ruthra kumar
53b5de85bb Merge pull request #54941 from ruthra-kumar/flaky_is_opening_filter_in_general_ledger
fix: flag to disable opening balance calculation in general ledger
2026-05-15 12:19:04 +05:30
ruthra kumar
28a2230d02 refactor: flag to disable opening balance calculation 2026-05-15 11:41:12 +05:30
Shllokkk
63daba9715 feat(company): add a default_letter_head_report field in company doctype 2026-05-14 20:13:23 +05:30
MochaMind
4380d710c7 fix: sync translations from crowdin (#54893)
* fix: Persian translations

* fix: Croatian translations

* fix: Swedish translations
2026-05-14 13:30:43 +02:00
Mihir Kandoi
78a79120ea fix: status not changing for dropshipped POs and SOs (#54934)
* fix: status not changing for dropshipped POs and SOs

* test: change test case to accomodate new flow
2026-05-14 08:26:51 +00:00
Khushi Rawat
930990434c Merge pull request #54935 from frappe/revert-54176-payment-entry-list-reconciliation-indicator
Revert "feat: show reconciled/unreconciled indicator in list view"
2026-05-14 12:52:35 +05:30
Khushi Rawat
c5e24eda69 Revert "feat: show reconciled/unreconciled indicator in list view" 2026-05-14 12:35:57 +05:30
rohitwaghchaure
06784d2a46 Merge pull request #54905 from rohitwaghchaure/fixed-support-67449
fix: posting date and time
2026-05-13 23:47:38 +05:30
Pandiyan P
f9dec73042 fix(stock): add whole number quantity validation in Stock Reconciliation (#54922) 2026-05-13 20:11:34 +05:30
rohitwaghchaure
3c993377aa chore: fix linter issue 2026-05-13 18:50:09 +05:30
Nishka Gosalia
45f05fbeaa fix(UX): Buying settings form cleanup (#54731)
* fix(UX): Buying settings form cleanup

* fix: controller approach modification

* fix: dark mode support
2026-05-13 14:53:04 +05:30
Mihir Kandoi
cf5e8ce878 Revert "fix: debit credit not equal in purchase transactions for mult… (#54906)
* Revert "fix: debit credit not equal in purchase transactions for multi currency"

This reverts commit 75bcea57f4.

* Revert "test: add test case"

This reverts commit 1d30a202c3.

* Revert "fix: include rejected qty in tax (purchase receipt)"

This reverts commit 8c9a88abbe.
2026-05-13 08:57:52 +00:00
rohitwaghchaure
c740f77a6f chore: fixed test case 2026-05-13 14:05:55 +05:30
Rohit Waghchaure
fb6c05f186 fix: posting date and time 2026-05-13 13:16:00 +05:30
Pandiyan P
bc07b2d3e5 fix: add warehouse vaildation for repack entry (#54866) 2026-05-13 11:51:49 +05:30
Loïc Oberle
d80a52ae22 refactor(supplier): Using query builder for get_rfq_total_items (#54877)
Use the query builder for get_rfq_total_items to assure compatibility with PostgreSQL.
2026-05-12 17:22:23 +00:00
Loïc Oberle
d128fb92cf refactor(supplier): Using query builder for get_total_accepted_amount (#54873)
Use the query builder for get_total_accepted_amount to assure compatibility with PostgreSQL.
2026-05-12 17:14:30 +00:00
Loïc Oberle
66914ac2fc refactor(supplier): Using query builder for get_total_rejected_items (#54872)
Use the query builder for get_total_rejected_items to assure compatibility with PostgreSQL.
2026-05-12 22:37:04 +05:30
Loïc Oberle
20d6b54590 refactor(supplier): Using query builder for get_total_received_items (#54870)
Use the query builder for get_total_received_items to assure compatibility with PostgreSQL
2026-05-12 22:36:07 +05:30
Loïc Oberle
573e37a78d refactor(supplier): Using query builder for get_total_rejected_amount (#54871)
Use the query builder for get_total_rejected_amount to assure compatibility with PostgreSQL.
2026-05-12 22:35:47 +05:30
Loïc Oberle
7a292f9ea6 refactor(supplier): Using query builder for get_total received (#54868)
Use the query builder for get_total_received to assure compatibility with PostgreSQL.
2026-05-12 22:34:30 +05:30
Loïc Oberle
876d4bdb75 refactor(supplier): Using query builder for get_sq_total_number (#54878)
Use the query builder for get_sq_total_number to assure compatibility with PostgreSQL.
2026-05-12 22:33:24 +05:30
Loïc Oberle
24530fa349 refactor(supplier): Using query builder for get_total_shipments (#54875)
Use the query builder for get_total_shipments to assure compatibility with PostgreSQL.
2026-05-12 22:32:57 +05:30
Loïc Oberle
5b7f07ddb1 refactor(supplier): Using query builder for get_total_received_amount (#54869)
Use the query builder for get_total_received_amount to assure compatibility with PostgreSQL.
2026-05-12 22:32:34 +05:30
Loïc Oberle
1a4748759d refactor(supplier): Using query builder for get_total_accepted_items (#54874)
Use the query builder for get_total_accepted_items to assure compatibility with PostgreSQL.
2026-05-12 22:31:34 +05:30
Loïc Oberle
c8f91ac4db refactor(supplier): Using query builder for get_rfq_total_number (#54876)
Use the query builder for get_rfq_total_number to assure compatibility with PostgreSQL.
2026-05-12 22:30:49 +05:30
Loïc Oberle
1e7a265037 refactor(supplier): Using query builder for get_sq_total_items (#54879)
Use the query builder for get_sq_total_items to assure compatibility with PostgreSQL.
2026-05-12 22:30:21 +05:30
Loïc Oberle
1b9eaed4d2 refactor(supplier): Using query builder for get_rfq_response_days (#54880)
Use the query builder for get_rfq_response_days to assure compatibility with PostgreSQL.
2026-05-12 22:29:31 +05:30
Soham-ambibuzz
5560f6c270 feat: Added Philippines chart of account json file (#53918)
* feat: Added philipinnes chart of account json file

Signed-off-by: Soham-ambibuzz <soham.pawar@ambibuzz.com>

* feat: made changes as per review comments and corrected indentation

* feat: made changes as per review comments

* feat: made changes as per review comments to resolve the issues

* fix: fixed changes as per review comments

Signed-off-by: soham7117 <sohampawar626@gmail.com>

* fix: fixed changes as per review comments on bank group account

Signed-off-by: soham7117 <sohampawar626@gmail.com>

---------

Signed-off-by: Soham-ambibuzz <soham.pawar@ambibuzz.com>
Signed-off-by: soham7117 <sohampawar626@gmail.com>
Co-authored-by: soham7117 <sohampawar626@gmail.com>
2026-05-12 21:48:55 +05:30
diptanilsaha
9134db9cd3 fix: added permission validation for deactivate_sales_person (#54884) 2026-05-12 16:01:08 +00:00
MochaMind
6e349569c7 fix: sync translations from crowdin (#54810)
* fix: Swedish translations

* fix: Croatian translations

* fix: Bosnian translations

* fix: French translations

* fix: Arabic translations

* fix: Czech translations

* fix: Danish translations

* fix: German translations

* fix: Hungarian translations

* fix: Italian translations

* fix: Dutch translations

* fix: Polish translations

* fix: Portuguese translations

* fix: Russian translations

* fix: Slovenian translations

* fix: Serbian (Cyrillic) translations

* fix: Swedish translations

* fix: Turkish translations

* fix: Chinese Simplified translations

* fix: Vietnamese translations

* fix: Portuguese, Brazilian translations

* fix: Indonesian translations

* fix: Persian translations

* fix: Thai translations

* fix: Croatian translations

* fix: Burmese translations

* fix: Bosnian translations

* fix: Norwegian Bokmal translations

* fix: Serbian (Latin) translations

* fix: Spanish translations

* fix: Esperanto translations

* fix: Swedish translations

* fix: Croatian translations

* fix: Bosnian translations
2026-05-12 21:10:00 +05:30
Jaypal Lakum
3532c1cc69 fix(task): update depends_on for closing date and review date #54850 (#54852) 2026-05-12 09:53:42 +00:00
Mihir Kandoi
b5527cf328 fix: raw material should not have target warehouse in manufacture entry (#54849) 2026-05-12 14:56:59 +05:30
Nishka Gosalia
631958314f Merge pull request #54835 from nishkagosalia/st-67801
fix: rename supplier wise stock analytics report
2026-05-12 12:37:36 +05:30
Nikhil Kothari
422ff15be5 fix: remove wrapper for list items in error messages (#54848) 2026-05-12 05:52:39 +00:00
Loïc Oberle
1d5ef62452 refactor(supplier): use frappe orm for criteria retrieval (#54841)
replace raw SQL with frappe.get_all in get_criteria_list to leverage
the standard Frappe API. This improves code readability and follows
framework best practices.
2026-05-11 21:27:10 +05:30
Loïc Oberle
2e958de95b refactor(supplier): use frappe orm for database queries (#54842)
replace raw SQL with frappe orm to leverage the framework's native
capabilities. this improves code maintainability and adheres to frappe
best practices.
2026-05-11 21:24:28 +05:30
nishkagosalia
85206e0278 fix: rename supplier wise stock analytics report 2026-05-11 16:24:57 +05:30
ervishnucs
01e382b106 fix: normalize date comparison to avoid datatype mismatch 2026-05-06 17:08:27 +05:30
Ejaaz Khan
c933c2bd53 refactor: remove dead print format 2026-04-29 21:35:11 +05:30
Sudharsanan11
8cf4402823 test(stock): add test to create pr for asset item without checking the stock account 2026-04-27 18:02:10 +05:30
Sudharsanan11
6fe08428c1 fix(stock): ignore fetching warehouse account for asset items 2026-04-27 18:02:06 +05:30
326 changed files with 206483 additions and 76706 deletions

52
.github/helper/merge_po_files.py vendored Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Overlay develop's .po translations onto hotfix's .po files.
Called by sync_hotfix_translations.sh before `bench update-po-files`.
Merge rules:
a. msgid absent from develop → keep hotfix's existing msgstr
b. language not yet in hotfix → copy file as-is (bench will filter to main.pot)
c. msgid present in both → use develop's msgstr
"""
from datetime import datetime, timezone
from pathlib import Path
from babel.messages.pofile import read_po, write_po
DEVELOP = Path("/tmp/develop-po/erpnext/locale/")
LOCALE = Path("./apps/erpnext/erpnext/locale/")
added = updated = 0
for src in sorted(DEVELOP.glob("*.po")):
dst = LOCALE / src.name
with src.open("rb") as f:
dev = read_po(f)
if not dst.exists():
dev.revision_date = datetime.now(timezone.utc)
with dst.open("wb") as f:
write_po(f, dev)
added += 1
print(f" [new] {src.name}")
continue
with dst.open("rb") as f:
hf = read_po(f)
changes = 0
for msg in hf:
if msg.id and msg.id in dev and dev[msg.id].string and dev[msg.id].string != msg.string:
msg.string = dev[msg.id].string
changes += 1
if changes:
hf.revision_date = datetime.now(timezone.utc)
with dst.open("wb") as f:
write_po(f, hf)
updated += 1
print(f" [updated] {src.name} ({changes} msgstr(s) from develop)")
else:
print(f" [no-op] {src.name}")
print(f"\n{added} new language(s), {updated} updated.")

View File

@@ -0,0 +1,121 @@
#!/bin/bash
# Syncs Crowdin translations from develop to a hotfix branch.
# Merge logic: see merge_po_files.py.
# Env: GH_TOKEN, PR_REVIEWER, GITHUB_WORKSPACE, APP_NAME, GITHUB_REPOSITORY
# (all set by Actions).
set -e
HOTFIX_BRANCH="${HOTFIX_BRANCH:?HOTFIX_BRANCH env var is required}"
APP_NAME="${APP_NAME:?APP_NAME env var is required}"
cd ~ || exit
echo "=== Setting up bench ==="
pip install frappe-bench
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)"
cd ./frappe-bench || exit
bench get-app --skip-assets "${APP_NAME}" "${GITHUB_WORKSPACE}"
echo "=== Setting up sync_translations_${HOTFIX_BRANCH} branch ==="
cd "./apps/${APP_NAME}" || exit
git config user.email "developers@erpnext.com"
git config user.name "frappe-pr-bot"
git remote set-url upstream "https://github.com/${GITHUB_REPOSITORY}.git"
git config remote.upstream.fetch "+refs/heads/*:refs/remotes/upstream/*"
gh auth setup-git
git fetch upstream "${HOTFIX_BRANCH}"
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/sync_translations_${HOTFIX_BRANCH}"
git merge -X theirs "upstream/${HOTFIX_BRANCH}" --no-edit
else
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/${HOTFIX_BRANCH}"
fi
cd ../.. || exit
echo "=== Fetching develop's .po files ==="
mkdir -p /tmp/develop-po
git -C "${GITHUB_WORKSPACE}" fetch origin develop
git -C "${GITHUB_WORKSPACE}" archive origin/develop "${APP_NAME}/locale/" \
| tar -xf - -C /tmp/develop-po/
po_count=$(find "/tmp/develop-po/${APP_NAME}/locale" -name "*.po" | wc -l)
if [ "${po_count}" -eq 0 ]; then
echo "ERROR: No .po files found in develop's archive. Aborting." >&2
exit 1
fi
echo "Extracted ${po_count} .po file(s) from develop."
echo "=== Merging and reconciling ==="
env/bin/python "${GITHUB_WORKSPACE}/.github/helper/merge_po_files.py"
bench update-po-files --app "${APP_NAME}"
cd "./apps/${APP_NAME}" || exit
if git diff --quiet "${APP_NAME}/locale/" && [ -z "$(git ls-files --others --exclude-standard "${APP_NAME}/locale/")" ]; then
echo "Translations are already up to date. No PR needed."
exit 0
fi
echo "Changed files:"
git diff --name-only "${APP_NAME}/locale/"
git ls-files --others --exclude-standard "${APP_NAME}/locale/"
echo "=== Committing ==="
while IFS= read -r file; do
git add "${file}"
lang=$(basename "${file}" .po)
git commit -m "chore: add ${lang} translation to ${HOTFIX_BRANCH}"
done < <(git ls-files --others --exclude-standard "${APP_NAME}/locale/" | grep '\.po$' | sort)
while IFS= read -r file; do
git add "${file}"
if ! git diff --staged --quiet -- "${file}"; then
lang=$(basename "${file}" .po)
git commit -m "chore: sync ${lang} translation to ${HOTFIX_BRANCH}"
else
git restore --staged -- "${file}"
fi
done < <(git diff --name-only "${APP_NAME}/locale/" | grep '\.po$' | sort)
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
git merge -X ours "upstream/sync_translations_${HOTFIX_BRANCH}" --no-edit
fi
git push -u upstream sync_translations_${HOTFIX_BRANCH}
echo "=== Opening PR (if not already open) ==="
existing_pr=$(gh pr list \
--base "${HOTFIX_BRANCH}" \
--head "sync_translations_${HOTFIX_BRANCH}" \
--state open \
--json number \
--jq 'length' \
-R "${GITHUB_REPOSITORY}")
if [ "${existing_pr}" -gt 0 ]; then
echo "PR already open — branch updated in place. No new PR needed."
exit 0
fi
gh pr create \
--base "${HOTFIX_BRANCH}" \
--head "sync_translations_${HOTFIX_BRANCH}" \
--title "chore: sync translations to ${HOTFIX_BRANCH}" \
--body "Automated sync of Crowdin translations from \`develop\` to \`${HOTFIX_BRANCH}\`.
A 3-way merge is performed per language, then \`bench update-po-files\` reconciles each \`.po\` against hotfix's \`main.pot\`:
| Case | Condition | Result |
|------|-----------|--------|
| **a** | \`msgid\` in hotfix's \`main.pot\`, **not** in develop's \`.po\` | Hotfix's existing \`msgstr\` is **preserved** (string removed from develop but still needed in hotfix) |
| **b** | \`msgid\` **not** in hotfix's \`main.pot\` | **Dropped** from hotfix's \`.po\` |
| **c** | \`msgid\` in both hotfix's \`main.pot\` and develop's \`.po\` | Develop's \`msgstr\` is used (Crowdin translation wins) |
Generated by the \`sync-hotfix-translations\` workflow." \
--label "translation" \
--label "skip-release-notes" \
--reviewer "${PR_REVIEWER}" \
-R "${GITHUB_REPOSITORY}"

View File

@@ -0,0 +1,70 @@
name: Build and Upload Assets
on:
push:
branches:
- develop
- 'version-*'
concurrency:
group: build-assets-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
jobs:
build-assets:
name: Build JS/CSS and upload to release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: frappe/frappe
path: apps/frappe
ref: ${{ github.ref_name }}
- uses: actions/checkout@v4
with:
path: apps/erpnext
- name: Create bench structure
run: |
mkdir -p sites
printf "frappe\nerpnext\n" > sites/apps.txt
- uses: actions/setup-node@v4
with:
node-version: 24
cache: yarn
cache-dependency-path: apps/frappe/yarn.lock
- name: Install frappe JS dependencies
working-directory: apps/frappe
run: yarn install --frozen-lockfile
- name: Install erpnext JS dependencies
working-directory: apps/erpnext
run: yarn install --frozen-lockfile --ignore-scripts
- name: Link node_modules into public/
working-directory: apps/frappe
run: ln -s "$PWD/node_modules" frappe/public/node_modules
- name: Build assets (production)
working-directory: apps/frappe
run: yarn run production
- name: Package assets
working-directory: apps/erpnext
run: tar czf erpnext-assets.tar.gz -C ../../sites/assets/erpnext dist
- name: Upload to rolling release
working-directory: apps/erpnext
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="assets-${GITHUB_REF_NAME//\//-}"
gh release create "$TAG" --prerelease --title "Assets: $GITHUB_REF_NAME" --notes "" 2>/dev/null || true
gh release upload "$TAG" erpnext-assets.tar.gz --clobber

View File

@@ -15,4 +15,4 @@ jobs:
- name: curl
run: |
apk add curl bash
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.CI_PAT }}" https://api.github.com/repos/frappe/frappe_docker/actions/workflows/build_stable.yml/dispatches -d '{"ref":"main"}'
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.CI_PAT }}" https://api.github.com/repos/frappe/frappe_docker/actions/workflows/core-build-stable.yml/dispatches -d '{"ref":"main"}'

View File

@@ -0,0 +1,52 @@
# Runner — maintain this file on each hotfix branch, not on develop.
#
# Fires when main.pot changes on this branch (i.e. after a POT update PR
# merges), or when dispatched by the orchestrator on develop (weekly schedule).
#
# Uses github.ref_name so the file is identical across all hotfix branches
# with no branch-specific edits required.
name: Run hotfix translation sync
on:
workflow_dispatch:
# One run at a time per branch. cancel-in-progress: false to avoid leaving
# an orphaned remote branch from a mid-flight git push + gh pr create.
concurrency:
group: sync-hotfix-translations-${{ github.ref_name }}
cancel-in-progress: false
jobs:
sync-translations:
name: Sync translations from develop into ${{ github.ref_name }}
runs-on: ubuntu-latest
permissions:
contents: write
env:
HOTFIX_BRANCH: ${{ github.ref_name }}
APP_NAME: ${{ github.event.repository.name }}
steps:
- name: Checkout ${{ env.HOTFIX_BRANCH }}
uses: actions/checkout@v6
with:
ref: ${{ env.HOTFIX_BRANCH }}
fetch-depth: 0
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
- name: Run sync script
run: |
bash "${GITHUB_WORKSPACE}/.github/helper/sync_hotfix_translations.sh"
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
PR_REVIEWER: diptanilsaha

View File

@@ -0,0 +1,36 @@
# Orchestrator — lives on develop only.
#
# Triggers on the weekly schedule and dispatches the runner workflow on each
# hotfix branch listed in the matrix. To add or remove a branch, edit the
# matrix below.
#
# POT-change triggers are handled by the runner on each hotfix branch
# (run-hotfix-translation-sync.yml), since GitHub only evaluates a workflow
# from the branch that receives the push.
name: Sync translations to hotfix branches
on:
schedule:
# 10:00 UTC Monday
- cron: "0 10 * * 1"
workflow_dispatch:
jobs:
trigger-runners:
name: Trigger sync → ${{ matrix.hotfix_branch }}
runs-on: ubuntu-latest
strategy:
matrix:
hotfix_branch:
- version-16-hotfix
fail-fast: false
steps:
- name: Dispatch runner on ${{ matrix.hotfix_branch }}
run: |
gh workflow run run-hotfix-translation-sync.yml \
--repo "${{ github.repository }}" \
--ref "${{ matrix.hotfix_branch }}"
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}

View File

@@ -9,16 +9,18 @@ export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
extends: [js.configs.recommended, tseslint.configs.recommended, reactRefresh.configs.vite],
plugins: {
"react-hooks": reactHooks,
},
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
onlyExportComponents: false,
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"react-refresh/only-export-components": "off",
},
},
]);

View File

@@ -41,7 +41,6 @@
"react-markdown": "^10.1.0",
"react-router": "^7.15.0",
"react-router-dom": "^7.15.0",
"react-virtuoso": "^4.18.6",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",

View File

@@ -1,13 +1,17 @@
const common_site_config = require('../../../sites/common_site_config.json');
import { readFileSync } from 'node:fs';
const common_site_config = JSON.parse(
readFileSync(new URL('../../../sites/common_site_config.json', import.meta.url), 'utf8')
) as { webserver_port: string | number };
const { webserver_port } = common_site_config;
export default {
'^/(app|api|assets|files|private)': {
target: `http://127.0.0.1:${webserver_port}`,
ws: true,
router: function(req) {
const site_name = req.headers.host.split(':')[0];
return `http://${site_name}:${webserver_port}`;
router: function (req) {
const site_name = req.headers?.host?.split(':')[0];
return `http://${site_name ?? 'localhost'}:${webserver_port}`;
}
}
};

View File

@@ -1,14 +1,15 @@
import { useEffect } from 'react'
import { lazy, useEffect } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { FrappeProvider } from 'frappe-react-sdk'
import { Toaster } from '@/components/ui/sonner'
import BankReconciliation from '@/pages/BankReconciliation'
import BankStatementImporterContainer from '@/pages/BankStatementImporterContainer'
import { TooltipProvider } from './components/ui/tooltip'
import BankStatementImporter from '@/pages/BankStatementImporter'
import { LucideProvider } from 'lucide-react'
import { ThemeProvider } from './components/ui/theme-provider'
import ViewBankStatementImportLog from './pages/ViewBankStatementImportLog'
import BankStatementImporterContainer from './pages/BankStatementImporterContainer'
const BankStatementImporter = lazy(() => import('@/pages/BankStatementImporter'))
const ViewBankStatementImportLog = lazy(() => import('@/pages/ViewBankStatementImportLog'))
function App() {
useEffect(() => {
@@ -43,7 +44,6 @@ function App() {
>
{window.frappe?.boot?.user?.name && window.frappe?.boot?.user?.name !== 'Guest' &&
<BrowserRouter basename={import.meta.env.VITE_BASE_NAME ? `/${import.meta.env.VITE_BASE_NAME}` : ''}>
<Routes>
<Route index element={<BankReconciliation />} />
<Route path="/statement-importer" element={<BankStatementImporterContainer />}>

View File

@@ -1,475 +1,42 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import _ from '@/lib/translate'
import { useAtomValue, useSetAtom } from 'jotai'
import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms'
import { HistoryIcon } from 'lucide-react'
import { useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useGetBankAccounts } from '../BankReconciliation/utils'
import { getCompanyCurrency } from '@/lib/company'
import { formatCurrency } from '@/lib/numbers'
import dayjs from 'dayjs'
import { cn } from '@/lib/utils'
import { formatDate } from '@/lib/date'
import { Separator } from '@/components/ui/separator'
import { slug } from '@/lib/frappe'
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
import { JournalEntry } from '@/types/Accounts/JournalEntry'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
import { toast } from 'sonner'
import { getErrorMessage } from '@/lib/frappe'
import ErrorBanner from '@/components/ui/error-banner'
import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
import BankLogo from '@/components/common/BankLogo'
import ActionLogDialog from './ActionLogDialog'
const ActionLog = () => {
const [isOpen, setIsOpen] = useState(false)
const [isOpen, setIsOpen] = useState(false)
useHotkeys('meta+z', () => {
setIsOpen(true)
}, {
enabled: true,
enableOnFormTags: false,
preventDefault: true
})
useHotkeys('meta+z', () => {
setIsOpen(true)
}, {
enabled: true,
enableOnFormTags: false,
preventDefault: true
})
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant={'outline'} isIconButton size='md'>
<HistoryIcon />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Reconciliation History")}
</TooltipContent>
</Tooltip>
<DialogContent className='min-w-[90vw]'>
<DialogHeader>
<DialogTitle>{_("Reconciliation History")}</DialogTitle>
<DialogDescription>{_("View all reconciliation actions taken in this session.")}</DialogDescription>
</DialogHeader>
<ActionLogDialogContent />
<DialogFooter>
<DialogClose asChild>
<Button variant={'outline'} size='md' onClick={() => setIsOpen(false)}>{_("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant={'outline'} isIconButton size='md'>
<HistoryIcon />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Reconciliation History")}
</TooltipContent>
</Tooltip>
{isOpen && (
<ActionLogDialog onClose={() => setIsOpen(false)} />
)}
</Dialog>
)
}
const ActionLogDialogContent = () => {
const actionLog = useAtomValue(bankRecActionLog)
return <div className='flex flex-col gap-2'>
{actionLog.map((action) => (
<div key={action.timestamp} className='flex flex-col gap-1'>
<ActionGroupHeader action={action} />
<div>
<div className='ms-2 border-s border-s-outline-gray-2 py-1'>
<div className='ms-5'>
{action.items.map((item, index) => (
<Row
item={item}
key={item.bankTransaction.name}
index={index}
action={action}
isLast={index === action.items.length - 1} />
))}
</div>
</div>
</div>
</div>
))}
{actionLog.length === 0 && <Empty>
<EmptyMedia>
<HistoryIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No reconciliation actions found")}</EmptyTitle>
<EmptyDescription>{_("You have not performed any reconciliations in this session yet.")}</EmptyDescription>
</EmptyHeader>
</Empty>}
</div>
}
const ActionGroupHeader = ({ action }: { action: ActionLogType }) => {
const label = useMemo(() => {
switch (action.type) {
case 'match':
return _("Matched")
case 'payment':
if (action.isBulk) {
return _("Bulk Payment")
}
return _("Payment")
case 'transfer':
if (action.isBulk) {
return _("Bulk Transfer")
}
return _("Transfer")
case 'bank_entry':
if (action.isBulk) {
return _("Bulk Bank Entry")
}
return _("Bank Entry")
default:
return _("Action")
}
}, [action])
return <div className='flex items-center gap-2 text-ink-gray-5'>
{action.type === 'match' && <GitCompareIcon className='w-4 h-4' />}
{action.type === 'payment' && <ReceiptIcon className='w-4 h-4' />}
{action.type === 'transfer' && <ArrowRightLeftIcon className='w-4 h-4' />}
{action.type === 'bank_entry' && <LandmarkIcon className='w-4 h-4' />}
<span className='flex items-center gap-2 text-sm'>
{label} - {dayjs(action.timestamp).fromNow()}
</span>
</div>
}
const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => {
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
if (item.bankTransaction.bank_account) {
return banks?.find((bank) => bank.name === item.bankTransaction.bank_account)
}
return null
}, [item.bankTransaction.bank_account, banks])
const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit
const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '')
return <div className='flex items-center gap-2 group'>
<div className={cn('p-3.5 group-hover:bg-surface-gray-1 border-s border-e border-t w-full', isLast ? 'rounded-b border-b' : '', index === 0 ? 'rounded-t' : '')}>
<div className='flex justify-between items-center'>
<div className='flex flex-col gap-2'>
<p className='text-p-base'>{item.bankTransaction.description}</p>
<div className='flex items-center gap-3'>
<div className='flex gap-2 items-center'>
<BankLogo bank={bank} className='h-4 mb-0' iconSize='16px' />
<span className='text-sm text-ink-gray-5'>{item.bankTransaction.bank_account}</span>
</div>
<Separator orientation='vertical' />
<div className='flex items-center gap-2 text-ink-gray-5 text-sm' title={_("Transaction Date")}>
<CalendarIcon className='w-4 h-4' />
<span className='text-sm'>{formatDate(item.bankTransaction.date, 'Do MMM YYYY')}</span>
</div>
<Separator orientation='vertical' />
<div>
<div className='flex items-center gap-1' title={isWithdrawal ? _("Spent") : _("Received")}>
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
<span className='text-sm text-ink-gray-5'>{formatCurrency(amount, currency)}</span>
</div>
</div>
</div>
</div>
<div className='flex justify-end items-center gap-2'>
<div className='text-end flex flex-col gap-2'>
<a
href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`}
target='_blank'
className='underline underline-offset-4 text-base'>
{["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name}
</a>
{item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && <PaymentEntryDetails item={item} />}
{item.voucher.reference_doctype === "Journal Entry" && <JournalEntryDetails item={item} bank={bank} />}
</div>
</div>
</div>
</div>
<div className='w-10 h-10 flex items-center justify-center'>
<CancelActionLogItem item={item} type={action.type} timestamp={action.timestamp} bank={bank} />
</div>
</div>
}
const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
return <div className='flex items-center gap-2 text-ink-gray-5 justify-end'>
<WalletIcon className='w-4 h-4' />
<JournalEntryAccountsTable item={item} bank={bank} />
</div>
}
const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
const accounts = useMemo(() => {
const allAccounts = (item.voucher.doc as JournalEntry).accounts
return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true)
}, [item, bank])
return <>
{accounts.length === 1 ? <span className='text-sm'>{accounts[0].account}</span> :
<HoverCard>
<HoverCardTrigger>
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{_("Split across {} accounts", [accounts.length.toString()])}</span>
</HoverCardTrigger>
<HoverCardContent className='w-full p-2' align='end'>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Account")}</TableHead>
<TableHead className='text-end'>{_("Debit")}</TableHead>
<TableHead className='text-end'>{_("Credit")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((account) => (
<TableRow key={account.account}>
<TableCell>{account.account}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(account.debit ?? 0, account.account_currency ?? '')}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(account.credit ?? 0, account.account_currency ?? '')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</HoverCardContent>
</HoverCard>
}</>
}
const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") {
return <TransferDetails item={item} className={className} />
}
const invoices = (item.voucher.doc as PaymentEntry).references ?? []
const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency
return <div className='flex items-center gap-3'>
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<UserIcon className='w-4 h-4' />
<span className='text-sm'>{(item.voucher.doc as PaymentEntry).party_name}</span>
</div>
<Separator orientation='vertical' />
<HoverCard>
<HoverCardTrigger>
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<ReceiptTextIcon className='w-4 h-4' />
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])}</span>
</div>
</HoverCardTrigger>
<HoverCardContent className='w-full p-2' align='end'>
<div className='flex flex-col gap-2'>
{invoices.map((invoice) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Document")}</TableHead>
<TableHead>{_("Invoice No")}</TableHead>
<TableHead>{_("Due Date")}</TableHead>
<TableHead className='text-end'>{_("Grand Total")}</TableHead>
<TableHead className='text-end'>{_("Allocated")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell><a href={`/desk/${slug(invoice.reference_doctype)}/${invoice.reference_name}`} target='_blank' className='underline underline-offset-4'>{invoice.reference_doctype}: {invoice.reference_name}</a></TableCell>
<TableCell>{invoice.bill_no ?? "-"}</TableCell>
<TableCell>{formatDate(invoice.due_date)}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.total_amount, currency ?? '')}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.allocated_amount, currency ?? '')}</TableCell>
</TableRow>
</TableBody>
</Table>
))}
</div>
</HoverCardContent>
</HoverCard>
</div>
}
const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
let transferAccount = ""
if (isWithdrawal) {
transferAccount = (item.voucher.doc as PaymentEntry).paid_to
} else {
transferAccount = (item.voucher.doc as PaymentEntry).paid_from
}
const transferBankAccount = banks?.find((bank) => bank.account === transferAccount)
return transferBankAccount
}, [banks, item])
return <div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<BankLogo bank={bank} className='h-5 mb-0' iconSize='16px' imageClassName='max-h-5' />
<span className='text-sm'>{bank?.account}</span>
</div>
}
const ACTION_TYPE_MAP = {
'bank_entry': _("Bank Entry"),
'payment': _("Payment"),
'transfer': _("Transfer"),
'match': _("Match"),
}
const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => {
const [isOpen, setIsOpen] = useState(false)
const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry')
const { mutate } = useSWRConfig()
const actionLog = useSetAtom(bankRecActionLog)
const dates = useAtomValue(bankRecDateAtom)
const matchFilters = useAtomValue(bankRecMatchFilters)
const selectedBank = useAtomValue(selectedBankAccountAtom)
const onUndo = () => {
call({
bank_transaction_id: item.bankTransaction.name,
voucher_type: item.voucher.reference_doctype,
voucher_id: item.voucher.reference_name,
}).then(() => {
toast.success(type === 'match' ? _("Unmatched") : _("Cancelled"))
if (selectedBank?.name === item.bankTransaction.bank_account) {
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
// Update the matching vouchers for the selected transaction
mutate(`bank-reconciliation-vouchers-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`)
}
setTimeout(() => {
actionLog((prev) => {
// Find the action and then remove the item from the action. If the action is empty, remove the action from the array
const action = prev.find((action) => action.timestamp === timestamp)
if (action) {
action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name)
}
// If the action is empty, remove the action from the array
if (action && action.items.length === 0) {
return prev.filter((a) => a.timestamp !== timestamp)
} else {
return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a)
}
})
}, 100)
setIsOpen(false)
}).catch((error) => {
toast.error(_("There was an error while performing the action."), {
duration: 5000,
description: getErrorMessage(error),
})
})
}
return <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<AlertDialogTrigger asChild>
<Button
variant={'ghost'}
isIconButton
theme='red'
title={_("Cancel")}
className='hover:text-ink-red-3 hover:bg-destructive/5 text-ink-gray-5 hidden group-hover:inline-flex'>
<CircleXIcon className='w-8 h-8' />
</Button>
</AlertDialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Cancel")}
</TooltipContent>
</Tooltip>
<AlertDialogContent className='min-w-3xl'>
<AlertDialogHeader>
<AlertDialogTitle>{type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])}</AlertDialogTitle>
<AlertDialogDescription>{type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])}</AlertDialogDescription>
</AlertDialogHeader>
{error && <ErrorBanner error={error} />}
<div className='flex flex-col gap-2'>
<SelectedTransactionDetails transaction={item.bankTransaction} />
<Table>
<TableRow>
<TableHead>{_("Action Type")}</TableHead>
<TableCell>{ACTION_TYPE_MAP[type]}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Voucher Type")}</TableHead>
<TableCell>{_(item.voucher.reference_doctype)}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Voucher Name")}</TableHead>
<TableCell><a href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`} target='_blank' className='underline underline-offset-4'>{item.voucher.reference_name}</a></TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Posting Date")}</TableHead>
<TableCell>{formatDate(item.voucher.posting_date, 'Do MMM YYYY')}</TableCell>
</TableRow>
{type === 'transfer' && item.voucher.doc && <TableRow>
<TableHead>{_("Transfer Account")}</TableHead>
<TableCell>
<TransferDetails item={item} className='text-ink-gray-8' />
</TableCell>
</TableRow>}
{type === 'payment' && item.voucher.doc && <TableRow>
<TableHead>{_("Payment Details")}</TableHead>
<TableCell>
<PaymentEntryDetails item={item} className='text-ink-gray-8' />
</TableCell>
</TableRow>}
{type === 'bank_entry' && item.voucher.doc && <TableRow>
<TableHead>{_("Account")}</TableHead>
<TableCell><JournalEntryAccountsTable item={item} bank={bank} /></TableCell>
</TableRow>}
</Table>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>
{_("Close")}
</AlertDialogCancel>
<Button theme="red" size='md' disabled={loading} onClick={onUndo}>
{loading ? <Loader2Icon className='w-4 h-4 animate-spin' /> : _(("Undo"))}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
export default ActionLog
export default ActionLog

View File

@@ -0,0 +1,34 @@
import { Button } from '@/components/ui/button'
import { DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import _ from '@/lib/translate'
import { Loader2Icon } from 'lucide-react'
import { lazy, Suspense } from 'react'
const ActionLogDialogBody = lazy(() => import('./ActionLogDialogBody'))
const ActionLogDialogFallback = () => (
<div className="flex flex-1 items-center justify-center min-h-[40vh]">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
)
const ActionLogDialog = ({ onClose }: { onClose: () => void }) => {
return (
<DialogContent className='min-w-[90vw]'>
<DialogHeader>
<DialogTitle>{_("Reconciliation History")}</DialogTitle>
<DialogDescription>{_("View all reconciliation actions taken in this session.")}</DialogDescription>
</DialogHeader>
<Suspense fallback={<ActionLogDialogFallback />}>
<ActionLogDialogBody />
</Suspense>
<DialogFooter>
<DialogClose asChild>
<Button variant={'outline'} size='md' onClick={onClose}>{_("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
)
}
export default ActionLogDialog

View File

@@ -0,0 +1,431 @@
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import _ from '@/lib/translate'
import { useAtomValue, useSetAtom } from 'jotai'
import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms'
import { useGetBankAccounts } from '../BankReconciliation/utils'
import { getCompanyCurrency } from '@/lib/company'
import { formatCurrency } from '@/lib/numbers'
import dayjs from 'dayjs'
import { cn } from '@/lib/utils'
import { formatDate } from '@/lib/date'
import { Separator } from '@/components/ui/separator'
import { slug } from '@/lib/frappe'
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
import { JournalEntry } from '@/types/Accounts/JournalEntry'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
import { toast } from 'sonner'
import { getErrorMessage } from '@/lib/frappe'
import ErrorBanner from '@/components/ui/error-banner'
import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
import BankLogo from '@/components/common/BankLogo'
const ActionLogDialogBody = () => {
const actionLog = useAtomValue(bankRecActionLog)
return <div className='flex flex-col gap-2'>
{actionLog.map((action) => (
<div key={action.timestamp} className='flex flex-col gap-1'>
<ActionGroupHeader action={action} />
<div>
<div className='ms-2 border-s border-s-outline-gray-2 py-1'>
<div className='ms-5'>
{action.items.map((item, index) => (
<Row
item={item}
key={item.bankTransaction.name}
index={index}
action={action}
isLast={index === action.items.length - 1} />
))}
</div>
</div>
</div>
</div>
))}
{actionLog.length === 0 && <Empty>
<EmptyMedia>
<HistoryIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No reconciliation actions found")}</EmptyTitle>
<EmptyDescription>{_("You have not performed any reconciliations in this session yet.")}</EmptyDescription>
</EmptyHeader>
</Empty>}
</div>
}
const ActionGroupHeader = ({ action }: { action: ActionLogType }) => {
const label = useMemo(() => {
switch (action.type) {
case 'match':
return _("Matched")
case 'payment':
if (action.isBulk) {
return _("Bulk Payment")
}
return _("Payment")
case 'transfer':
if (action.isBulk) {
return _("Bulk Transfer")
}
return _("Transfer")
case 'bank_entry':
if (action.isBulk) {
return _("Bulk Bank Entry")
}
return _("Bank Entry")
default:
return _("Action")
}
}, [action])
return <div className='flex items-center gap-2 text-ink-gray-5'>
{action.type === 'match' && <GitCompareIcon className='w-4 h-4' />}
{action.type === 'payment' && <ReceiptIcon className='w-4 h-4' />}
{action.type === 'transfer' && <ArrowRightLeftIcon className='w-4 h-4' />}
{action.type === 'bank_entry' && <LandmarkIcon className='w-4 h-4' />}
<span className='flex items-center gap-2 text-sm'>
{label} - {dayjs(action.timestamp).fromNow()}
</span>
</div>
}
const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => {
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
if (item.bankTransaction.bank_account) {
return banks?.find((bank) => bank.name === item.bankTransaction.bank_account)
}
return null
}, [item.bankTransaction.bank_account, banks])
const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit
const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '')
return <div className='flex items-center gap-2 group'>
<div className={cn('p-3.5 group-hover:bg-surface-gray-1 border-s border-e border-t w-full', isLast ? 'rounded-b border-b' : '', index === 0 ? 'rounded-t' : '')}>
<div className='flex justify-between items-center'>
<div className='flex flex-col gap-2'>
<p className='text-p-base'>{item.bankTransaction.description}</p>
<div className='flex items-center gap-3'>
<div className='flex gap-2 items-center'>
<BankLogo bank={bank} className='h-4 mb-0' iconSize='16px' />
<span className='text-sm text-ink-gray-5'>{item.bankTransaction.bank_account}</span>
</div>
<Separator orientation='vertical' />
<div className='flex items-center gap-2 text-ink-gray-5 text-sm' title={_("Transaction Date")}>
<CalendarIcon className='w-4 h-4' />
<span className='text-sm'>{formatDate(item.bankTransaction.date, 'Do MMM YYYY')}</span>
</div>
<Separator orientation='vertical' />
<div>
<div className='flex items-center gap-1' title={isWithdrawal ? _("Spent") : _("Received")}>
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
<span className='text-sm text-ink-gray-5'>{formatCurrency(amount, currency)}</span>
</div>
</div>
</div>
</div>
<div className='flex justify-end items-center gap-2'>
<div className='text-end flex flex-col gap-2'>
<a
href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`}
target='_blank'
className='underline underline-offset-4 text-base'>
{["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name}
</a>
{item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && <PaymentEntryDetails item={item} />}
{item.voucher.reference_doctype === "Journal Entry" && <JournalEntryDetails item={item} bank={bank} />}
</div>
</div>
</div>
</div>
<div className='w-10 h-10 flex items-center justify-center'>
<CancelActionLogItem item={item} type={action.type} timestamp={action.timestamp} bank={bank} />
</div>
</div>
}
const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
return <div className='flex items-center gap-2 text-ink-gray-5 justify-end'>
<WalletIcon className='w-4 h-4' />
<JournalEntryAccountsTable item={item} bank={bank} />
</div>
}
const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
const accounts = useMemo(() => {
const allAccounts = (item.voucher.doc as JournalEntry).accounts
return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true)
}, [item, bank])
return <>
{accounts.length === 1 ? <span className='text-sm'>{accounts[0].account}</span> :
<HoverCard>
<HoverCardTrigger>
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{_("Split across {} accounts", [accounts.length.toString()])}</span>
</HoverCardTrigger>
<HoverCardContent className='w-full p-2' align='end'>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Account")}</TableHead>
<TableHead className='text-end'>{_("Debit")}</TableHead>
<TableHead className='text-end'>{_("Credit")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((account) => (
<TableRow key={account.account}>
<TableCell>{account.account}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(account.debit ?? 0, account.account_currency ?? '')}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(account.credit ?? 0, account.account_currency ?? '')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</HoverCardContent>
</HoverCard>
}</>
}
const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") {
return <TransferDetails item={item} className={className} />
}
const invoices = (item.voucher.doc as PaymentEntry).references ?? []
const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency
return <div className='flex items-center gap-3'>
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<UserIcon className='w-4 h-4' />
<span className='text-sm'>{(item.voucher.doc as PaymentEntry).party_name}</span>
</div>
<Separator orientation='vertical' />
<HoverCard>
<HoverCardTrigger>
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<ReceiptTextIcon className='w-4 h-4' />
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])}</span>
</div>
</HoverCardTrigger>
<HoverCardContent className='w-full p-2' align='end'>
<div className='flex flex-col gap-2'>
{invoices.map((invoice) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Document")}</TableHead>
<TableHead>{_("Invoice No")}</TableHead>
<TableHead>{_("Due Date")}</TableHead>
<TableHead className='text-end'>{_("Grand Total")}</TableHead>
<TableHead className='text-end'>{_("Allocated")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell><a href={`/desk/${slug(invoice.reference_doctype)}/${invoice.reference_name}`} target='_blank' className='underline underline-offset-4'>{invoice.reference_doctype}: {invoice.reference_name}</a></TableCell>
<TableCell>{invoice.bill_no ?? "-"}</TableCell>
<TableCell>{formatDate(invoice.due_date)}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.total_amount, currency ?? '')}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.allocated_amount, currency ?? '')}</TableCell>
</TableRow>
</TableBody>
</Table>
))}
</div>
</HoverCardContent>
</HoverCard>
</div>
}
const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
let transferAccount = ""
if (isWithdrawal) {
transferAccount = (item.voucher.doc as PaymentEntry).paid_to
} else {
transferAccount = (item.voucher.doc as PaymentEntry).paid_from
}
const transferBankAccount = banks?.find((bank) => bank.account === transferAccount)
return transferBankAccount
}, [banks, item])
return <div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<BankLogo bank={bank} className='h-5 mb-0' iconSize='16px' imageClassName='max-h-5' />
<span className='text-sm'>{bank?.account}</span>
</div>
}
const ACTION_TYPE_MAP = {
'bank_entry': _("Bank Entry"),
'payment': _("Payment"),
'transfer': _("Transfer"),
'match': _("Match"),
}
const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => {
const [isOpen, setIsOpen] = useState(false)
const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry')
const { mutate } = useSWRConfig()
const actionLog = useSetAtom(bankRecActionLog)
const dates = useAtomValue(bankRecDateAtom)
const matchFilters = useAtomValue(bankRecMatchFilters)
const selectedBank = useAtomValue(selectedBankAccountAtom)
const onUndo = () => {
call({
bank_transaction_id: item.bankTransaction.name,
voucher_type: item.voucher.reference_doctype,
voucher_id: item.voucher.reference_name,
}).then(() => {
toast.success(type === 'match' ? _("Unmatched") : _("Cancelled"))
if (selectedBank?.name === item.bankTransaction.bank_account) {
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
// Update the matching vouchers for the selected transaction
mutate(`bank-reconciliation-vouchers-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`)
}
setTimeout(() => {
actionLog((prev) => {
// Find the action and then remove the item from the action. If the action is empty, remove the action from the array
const action = prev.find((action) => action.timestamp === timestamp)
if (action) {
action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name)
}
// If the action is empty, remove the action from the array
if (action && action.items.length === 0) {
return prev.filter((a) => a.timestamp !== timestamp)
} else {
return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a)
}
})
}, 100)
setIsOpen(false)
}).catch((error) => {
toast.error(_("There was an error while performing the action."), {
duration: 5000,
description: getErrorMessage(error),
})
})
}
return <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<AlertDialogTrigger asChild>
<Button
variant={'ghost'}
isIconButton
theme='red'
title={_("Cancel")}
className='hover:text-ink-red-3 hover:bg-destructive/5 text-ink-gray-5 hidden group-hover:inline-flex'>
<CircleXIcon className='w-8 h-8' />
</Button>
</AlertDialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Cancel")}
</TooltipContent>
</Tooltip>
<AlertDialogContent className='min-w-3xl'>
<AlertDialogHeader>
<AlertDialogTitle>{type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])}</AlertDialogTitle>
<AlertDialogDescription>{type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])}</AlertDialogDescription>
</AlertDialogHeader>
{error && <ErrorBanner error={error} />}
<div className='flex flex-col gap-2'>
<SelectedTransactionDetails transaction={item.bankTransaction} />
<Table>
<TableRow>
<TableHead>{_("Action Type")}</TableHead>
<TableCell>{ACTION_TYPE_MAP[type]}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Voucher Type")}</TableHead>
<TableCell>{_(item.voucher.reference_doctype)}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Voucher Name")}</TableHead>
<TableCell><a href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`} target='_blank' className='underline underline-offset-4'>{item.voucher.reference_name}</a></TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Posting Date")}</TableHead>
<TableCell>{formatDate(item.voucher.posting_date, 'Do MMM YYYY')}</TableCell>
</TableRow>
{type === 'transfer' && item.voucher.doc && <TableRow>
<TableHead>{_("Transfer Account")}</TableHead>
<TableCell>
<TransferDetails item={item} className='text-ink-gray-8' />
</TableCell>
</TableRow>}
{type === 'payment' && item.voucher.doc && <TableRow>
<TableHead>{_("Payment Details")}</TableHead>
<TableCell>
<PaymentEntryDetails item={item} className='text-ink-gray-8' />
</TableCell>
</TableRow>}
{type === 'bank_entry' && item.voucher.doc && <TableRow>
<TableHead>{_("Account")}</TableHead>
<TableCell><JournalEntryAccountsTable item={item} bank={bank} /></TableCell>
</TableRow>}
</Table>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>
{_("Close")}
</AlertDialogCancel>
<Button theme="red" size='md' disabled={loading} onClick={onUndo}>
{loading ? <Loader2Icon className='w-4 h-4 animate-spin' /> : _(("Undo"))}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
export default ActionLogDialogBody

View File

@@ -83,7 +83,7 @@ const BankClearanceSummaryView = () => {
toast.success(_("Copied to clipboard"))
})
},
[copyToClipboard, _],
[copyToClipboard],
)
const accountCurrency = useMemo(
@@ -200,7 +200,7 @@ const BankClearanceSummaryView = () => {
},
},
],
[_, accountCurrency, bankAccount, companyID, mutate, onCopy],
[accountCurrency, bankAccount, companyID, mutate, onCopy],
)
return <div className="space-y-4 py-2">

View File

@@ -1,831 +1,32 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai"
import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose } from "@/components/ui/dialog"
import { useAtom } from "jotai"
import { bankRecRecordJournalEntryModalAtom } from "./bankRecAtoms"
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader } from "@/components/ui/dialog"
import { ModalContentFallback } from "@/components/ui/modal-content-fallback"
import _ from "@/lib/translate"
import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils"
import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form"
import { JournalEntry } from "@/types/Accounts/JournalEntry"
import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company"
import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk"
import { toast } from "sonner"
import ErrorBanner from "@/components/ui/error-banner"
import { Button } from "@/components/ui/button"
import SelectedTransactionDetails from "./SelectedTransactionDetails"
import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements"
import { Form } from "@/components/ui/form"
import { useCallback, useContext, useMemo, useRef, useState } from "react"
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 { flt, formatCurrency } from "@/lib/numbers"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import SelectedTransactionsTable from "./SelectedTransactionsTable"
import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount"
import { BankTransaction } from "@/types/Accounts/BankTransaction"
import FileUploadBanner from "@/components/common/FileUploadBanner"
import { Label } from "@/components/ui/label"
import { FileDropzone } from "@/components/ui/file-dropzone"
import { useGetAccounts } from "@/components/common/AccountsDropdown"
import { useHotkeys } from "react-hotkeys-hook"
import { lazy, Suspense } from "react"
const RecordBankEntryModalContent = lazy(() => import('./BankEntryModalContent'))
const BankEntryModal = () => {
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className='min-w-[95vw]'>
<DialogHeader>
<DialogTitle>{_("Bank Entry")}</DialogTitle>
<DialogDescription>
{_("Record a journal entry for expenses, income or split transactions.")}
</DialogDescription>
</DialogHeader>
<RecordBankEntryModalContent />
</DialogContent>
</Dialog>
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className='min-w-[95vw]'>
<DialogHeader>
<DialogTitle>{_("Bank Entry")}</DialogTitle>
<DialogDescription>
{_("Record a journal entry for expenses, income or split transactions.")}
</DialogDescription>
</DialogHeader>
{isOpen && (
<Suspense fallback={<ModalContentFallback />}>
<RecordBankEntryModalContent />
</Suspense>
)}
</DialogContent>
</Dialog>
)
}
const RecordBankEntryModalContent = () => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
return <div className='p-4'>
<span className='text-center'>{_("No transaction selected")}</span>
</div>
}
if (selectedTransaction.length === 1) {
return <BankEntryForm
selectedTransaction={selectedTransaction[0]} />
}
return <BulkBankEntryForm
selectedTransactions={selectedTransaction}
/>
}
const BulkBankEntryForm = ({ selectedTransactions }: { selectedTransactions: UnreconciledTransaction[] }) => {
const form = useForm<{
account: string
}>({
defaultValues: {
account: ''
}
})
const { call, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_bank_entry_and_reconcile')
const onReconcile = useRefreshUnreconciledTransactions()
const addToActionLog = useUpdateActionLog()
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
const onSubmit = (data: { account: string }) => {
call({
bank_transactions: selectedTransactions.map(transaction => transaction.name),
account: data.account
}).then(({ message }) => {
addToActionLog({
type: 'bank_entry',
timestamp: (new Date()).getTime(),
isBulk: true,
items: message.map((item) => ({
bankTransaction: item.transaction,
voucher: {
reference_doctype: "Journal Entry",
reference_name: item.journal_entry.name,
doc: item.journal_entry,
posting_date: item.journal_entry.posting_date,
}
})),
bulkCommonData: {
account: data.account,
}
})
toast.success(_("Bank Entries Created"), {
duration: 4000,
})
// Set this to the last selected transaction
onReconcile(selectedTransactions[selectedTransactions.length - 1])
setIsOpen(false)
})
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-4">
{error && <ErrorBanner error={error} />}
<SelectedTransactionsTable />
<div className="grid grid-cols-3 gap-4">
<AccountFormField
name='account'
filterFunction={(acc) => {
// Do not allow payable and receivable accounts
return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable'
}}
label={_('Account')}
isRequired
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
interface BankEntryFormData extends Pick<JournalEntry, 'voucher_type' | 'cheque_date' | 'posting_date' | 'cheque_no' | 'user_remark'> {
entries: JournalEntry['accounts']
}
const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: UnreconciledTransaction }) => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
const onClose = () => {
setIsOpen(false)
}
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
const defaultAccounts = useMemo(() => {
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
const accounts: Partial<JournalEntryAccount>[] = [
{
account: selectedBankAccount?.account ?? '',
bank_account: selectedTransaction.bank_account,
// Bank is debited if it's a deposit
debit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
credit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
party_type: '',
party: '',
cost_center: ''
}]
// If there is no rule, we can just add the entries for the bank account transaction and the other side will be the reverse
if (!rule) {
accounts.push(
{
account: '',
// Amounts will be the reverse of the bank account transaction
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
}
)
} else {
// Rule exists, so we need to check the type of rule
if (!rule.bank_entry_type || rule.bank_entry_type === "Single Account") {
// Only a single account needs to be added
accounts.push({
account: rule.account ?? '',
// Amounts will be the reverse of the bank account transaction
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
})
} else {
// For multiple accounts, we need to loop over and add entries for each
// The last row will just be the remaining amount
let hasTotallyEmptyRowEarlier = false;
let totalDebits = isWithdrawal ? 0 : selectedTransaction.unallocated_amount ?? 0
let totalCredits = isWithdrawal ? selectedTransaction.unallocated_amount ?? 0 : 0
for (let i = 0; i < (rule.accounts?.length ?? 0); i++) {
const acc = rule.accounts?.[i]
// If it's the last row, add the difference amount
if (i === (rule.accounts?.length ?? 0) - 1 && !hasTotallyEmptyRowEarlier) {
const differenceAmount = flt(totalDebits - totalCredits, 2)
accounts.push({
account: acc?.account ?? '',
debit: differenceAmount > 0 ? 0 : Math.abs(differenceAmount),
credit: differenceAmount > 0 ? Math.abs(differenceAmount) : 0,
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
user_remark: acc?.user_remark ?? '',
})
} 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;
}
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
totalDebits = flt(totalDebits + computedDebit, 2)
totalCredits = flt(totalCredits + computedCredit, 2)
accounts.push({
account: acc?.account ?? '',
debit: computedDebit,
credit: computedCredit,
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
user_remark: acc?.user_remark ?? '',
})
}
}
}
}
return accounts
}, [rule, selectedTransaction, selectedBankAccount])
const form = useForm<BankEntryFormData>({
defaultValues: {
voucher_type: selectedBankAccount?.is_credit_card ? 'Credit Card Entry' : 'Bank Entry',
cheque_date: selectedTransaction.date,
posting_date: selectedTransaction.date,
cheque_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
user_remark: selectedTransaction.description,
entries: defaultAccounts,
}
})
const onReconcile = useRefreshUnreconciledTransactions()
const { call: createBankEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bank_entry_and_reconcile')
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
const addToActionLog = useUpdateActionLog()
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [files, setFiles] = useState<File[]>([])
const onSubmit = (data: BankEntryFormData) => {
createBankEntry({
bank_transaction_name: selectedTransaction.name,
...data
}).then(async ({ message }) => {
addToActionLog({
type: 'bank_entry',
isBulk: false,
timestamp: (new Date()).getTime(),
items: [
{
bankTransaction: message.transaction,
voucher: {
reference_doctype: "Journal Entry",
reference_name: message.journal_entry.name,
reference_no: message.journal_entry.cheque_no,
reference_date: message.journal_entry.cheque_date,
posting_date: message.journal_entry.posting_date,
doc: message.journal_entry,
}
}
]
})
toast.success(_("Bank Entry Created"), {
duration: 4000,
closeButton: true,
action: {
label: _("Undo"),
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
},
actionButtonStyle: {
backgroundColor: "rgb(0, 138, 46)"
}
})
if (files.length > 0) {
setIsUploading(true)
const uploadPromises = files.map(f => {
return frappeFile.uploadFile(f, {
isPrivate: true,
doctype: "Journal Entry",
docname: message.journal_entry.name,
}, (_bytesUploaded, _totalBytes, progress) => {
setUploadProgress((currentProgress) => {
//If there are multiple files, we need to add the progress to the current progress
return currentProgress + ((progress?.progress ?? 0) / files.length)
})
})
})
return Promise.all(uploadPromises).then(() => {
setUploadProgress(0)
setIsUploading(false)
}).catch((error) => {
console.error(error)
toast.error(_("Error uploading attachments"), {
duration: 4000,
})
setIsUploading(false)
})
} else {
return Promise.resolve()
}
}).then(() => {
onReconcile(selectedTransaction)
onClose()
})
}
useHotkeys('meta+s', () => {
form.handleSubmit(onSubmit)()
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: true
})
if (isUploading && isCompleted) {
return <FileUploadBanner uploadProgress={uploadProgress} />
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<div className='grid grid-cols-2 gap-4'>
<SelectedTransactionDetails transaction={selectedTransaction} />
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-2 gap-4'>
<DateField
name='posting_date'
label={_("Posting Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
<DateField
name='cheque_date'
label={_("Reference Date")}
isRequired
inputProps={{ autoFocus: false }}
rules={{
required: _("Reference Date is required"),
}}
/>
</div>
<DataField name='cheque_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }}
rules={{
required: _("Reference is required"),
}} />
</div>
</div>
<div>
<Entries company={selectedTransaction.company ?? ''} isWithdrawal={isWithdrawal} currency={selectedTransaction.currency ?? getCompanyCurrency(selectedTransaction.company ?? '')} />
</div>
<div className='flex flex-col gap-2'>
<div className='grid grid-cols-2 gap-4'>
<SmallTextField
name='user_remark'
label={_("Remarks")}
/>
<div
data-slot="form-item"
className="flex flex-col gap-2"
>
<Label>{_("Attachments")}</Label>
<FileDropzone files={files} setFiles={setFiles} />
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => {
const { getValues, setValue, control } = useFormContext<BankEntryFormData>()
const { call } = useContext(FrappeContext) as FrappeConfig
const partyMapRef = useRef<Record<string, string>>({})
const onPartyChange = (value: string, index: number) => {
// Get the account for the party type
if (value) {
if (partyMapRef.current[value]) {
setValue(`entries.${index}.account`, partyMapRef.current[value])
} else {
call.get('erpnext.accounts.party.get_party_account', {
party: value,
party_type: getValues(`entries.${index}.party_type`),
company: company
}).then((result: { message: string }) => {
setValue(`entries.${index}.account`, result.message)
partyMapRef.current[value] = result.message
})
}
} else {
setValue(`entries.${index}.account`, '')
}
}
const { data: accounts } = useGetAccounts()
const onAccountChange = (value: string, index: number) => {
// If it's an income or expense account, get the default cost center
if (value) {
const account = accounts?.find((acc) => acc.name === value)
if (account && account.report_type === "Profit and Loss") {
// Set the default company cost center
setValue(`entries.${index}.cost_center`, getCompanyCostCenter(company) ?? '')
return
}
}
setValue(`entries.${index}.cost_center`, '')
}
const { fields, append, remove } = useFieldArray({
control: control,
name: 'entries'
})
const onAdd = useCallback(() => {
const existingEntries = getValues('entries')
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
const remainingAmount = flt(totalDebits - totalCredits, 2)
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
append({
party_type: '',
party: '',
account: '',
debit: debitAmount,
credit: creditAmount,
cost_center: getCompanyCostCenter(company) ?? ''
} as JournalEntryAccount, {
focusName: `entries.${existingEntries.length}.account`
})
}, [company, append, getValues])
const [selectedRows, setSelectedRows] = useState<number[]>([])
const onSelectRow = useCallback((index: number) => {
setSelectedRows(prev => {
if (prev.includes(index)) {
return prev.filter(i => i !== index)
}
return [...prev, index]
})
}, [])
const onSelectAll = useCallback(() => {
setSelectedRows(prev => {
if (prev.length === fields.length) {
return []
}
return [...fields.map((_, index) => index)]
})
}, [fields])
const onRemove = useCallback(() => {
remove(selectedRows)
setSelectedRows([])
}, [remove, selectedRows])
/**
* When add difference is clicked, check if the last row has nothing filled in.
* If last row is empty (no debit or credit), then set that row's amount. Else, add a new row with the difference amount.
*/
const onAddDifferenceClicked = () => {
const existingEntries = getValues('entries')
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
const lastIndex = existingEntries.length - 1
const isLastRowEmpty = (existingEntries[lastIndex]?.debit === 0 || existingEntries[lastIndex]?.debit === undefined) && (existingEntries[lastIndex]?.credit === 0 || existingEntries[lastIndex]?.credit === undefined)
const remainingAmount = flt(totalDebits - totalCredits, 2)
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
if (isLastRowEmpty) {
setValue(`entries.${lastIndex}.debit`, debitAmount)
setValue(`entries.${lastIndex}.credit`, creditAmount)
} else {
append({
party_type: '',
party: '',
account: '',
debit: debitAmount,
credit: creditAmount,
cost_center: getCompanyCostCenter(company) ?? ''
} as JournalEntryAccount, {
focusName: `entries.${existingEntries.length}.account`
})
}
}
return <div className="flex flex-col gap-2">
<Table>
<TableHeader>
<TableRow>
<TableHead><Checkbox
disabled={fields.length === 0}
// Make this accessible to screen readers
aria-label={_("Select all")}
checked={selectedRows.length > 0 && selectedRows.length === fields.length}
onCheckedChange={onSelectAll} /></TableHead>
<TableHead>{_("Party")}</TableHead>
<TableHead>{_("Account")}</TableHead>
<TableHead>{_("Cost Center")}</TableHead>
<TableHead>{_("Remarks")}</TableHead>
<TableHead className="text-end">{_("Debit")}</TableHead>
<TableHead className="text-end">{_("Credit")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, index) => (
<TableRow key={field.id} className={index === 0 ? 'bg-surface-gray-1 cursor-not-allowed' : ''} title={index === 0 ? _("This is the bank account entry. You cannot edit it.") : ''}>
<TableCell>
<Checkbox
checked={selectedRows.includes(index)}
onCheckedChange={() => onSelectRow(index)}
// Make this accessible to screen readers
aria-label={_("Select row {0}", [String(index + 1)])}
disabled={index === 0}
/>
</TableCell>
<TableCell className="align-top">
<div className="flex">
<PartyTypeFormField
name={`entries.${index}.party_type`}
label={_("Party Type")}
isRequired
readOnly={index === 0}
hideLabel
inputProps={{
type: isWithdrawal ? 'Payable' : 'Receivable',
triggerProps: {
className: 'rounded-e-none',
tabIndex: -1
},
readOnly: index === 0,
}} />
<PartyField index={index} onChange={onPartyChange} readOnly={index === 0} />
</div>
</TableCell>
<TableCell className="align-top">
<AccountFormField
name={`entries.${index}.account`}
label={_("Account")}
rules={{
required: _("Account is required"),
onChange: (event) => {
onAccountChange(event.target.value, index)
}
}}
buttonClassName="min-w-64"
readOnly={index === 0}
isRequired
hideLabel
/>
</TableCell>
<TableCell className="align-top">
<LinkFormField
doctype="Cost Center"
name={`entries.${index}.cost_center`}
label={_("Cost Center")}
filters={[["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]}
buttonClassName="min-w-48"
readOnly={index === 0}
hideLabel
/>
</TableCell>
<TableCell className="align-top">
<DataField
name={`entries.${index}.user_remark`}
label={_("Remarks")}
readOnly={index === 0}
inputProps={{
placeholder: _("e.g. Bank Charges"),
className: 'min-w-64',
readOnly: index === 0
}}
hideLabel
/>
</TableCell>
<TableCell className={cn("text-end align-top")}>
<CurrencyFormField
name={`entries.${index}.debit`}
label={_("Debit")}
isRequired
hideLabel
readOnly={index === 0}
style={index === 0 ? !isWithdrawal ? {
color: "var(--color-ink-gray-8)",
} : {} : {}}
currency={currency}
leftSlot={index === 0 && !isWithdrawal ? <Tooltip>
<TooltipTrigger asChild><ArrowDownRight className="text-ink-green-3" /></TooltipTrigger>
<TooltipContent>{_("Bank account debit for deposit")}</TooltipContent>
</Tooltip> : undefined}
/>
</TableCell>
<TableCell className={cn("text-end align-top")}>
<CurrencyFormField
name={`entries.${index}.credit`}
style={index === 0 && isWithdrawal ? {
color: "var(--color-ink-gray-8)",
} : {}}
label={_("Credit")}
isRequired
hideLabel
readOnly={index === 0}
currency={currency}
leftSlot={index === 0 && isWithdrawal ? <Tooltip>
<TooltipTrigger asChild><ArrowUpRight className="text-ink-red-3" /></TooltipTrigger>
<TooltipContent>{_("Bank account credit for withdrawal")}</TooltipContent>
</Tooltip> : undefined}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex justify-between gap-2">
<div className="flex gap-2 justify-end">
<div>
<Button size='sm' type='button' variant={'outline'} onClick={onAdd}><Plus /> {_("Add Row")}</Button>
</div>
{selectedRows.length > 0 && <div>
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
</div>}
</div>
<Summary currency={currency} addRow={onAddDifferenceClicked} />
</div>
</div>
}
const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => {
const { control } = useFormContext<BankEntryFormData>()
const party_type = useWatch({
control,
name: `entries.${index}.party_type`
})
if (!party_type) {
return <DataField
name={`entries.${index}.party`}
label={_("Party")}
isRequired
inputProps={{
disabled: true,
className: 'rounded-s-none border-s-0 min-w-64'
}}
hideLabel
/>
}
return <LinkFormField
name={`entries.${index}.party`}
label={_("Party")}
rules={{
onChange: (event) => {
onChange(event.target.value, index)
},
}}
hideLabel
readOnly={readOnly}
buttonClassName="rounded-s-none border-s-0 min-w-64"
doctype={party_type}
/>
}
const Summary = ({ currency, addRow }: { currency: string, addRow: () => void }) => {
const { control } = useFormContext<BankEntryFormData>()
const entries = useWatch({ control, name: 'entries' })
const { total, totalCredits, totalDebits } = useMemo(() => {
// Do a total debits - total credits
const totalDebits = entries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
const totalCredits = entries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
return { total: flt(totalDebits - totalCredits, 2), totalDebits, totalCredits }
}, [entries])
const onAddRow = useCallback(() => {
addRow()
}, [addRow])
const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => {
return <span className={cn("w-32 text-end font-medium text-sm font-numeric", className)}>{children}</span>
}
return <div className="flex flex-col gap-2 items-end">
<div className="flex gap-2 justify-between">
<TextComponent>{_("Total Debit")}</TextComponent>
<TextComponent>{formatCurrency(totalDebits, currency)}</TextComponent>
</div>
<div className="flex gap-2 justify-between">
<TextComponent>{_("Total Credit")}</TextComponent>
<TextComponent>{formatCurrency(totalCredits, currency)}</TextComponent>
</div>
{total !== 0 && <div className="flex gap-2 justify-between">
<TextComponent>{_("Difference")}</TextComponent>
<Tooltip>
<TooltipTrigger asChild>
<Button type='button' variant='link' className="p-0 text-ink-red-3 underline h-fit" role='button' onClick={onAddRow}>
<TextComponent className='text-ink-red-3'>{formatCurrency(total, currency)}</TextComponent>
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Add a row with the difference amount")}
</TooltipContent>
</Tooltip>
</div>}
</div>
}
export default BankEntryModal

View File

@@ -0,0 +1,811 @@
import { useAtomValue, useSetAtom } from "jotai"
import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { DialogFooter, DialogClose } from "@/components/ui/dialog"
import _ from "@/lib/translate"
import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils"
import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form"
import { JournalEntry } from "@/types/Accounts/JournalEntry"
import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company"
import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk"
import { toast } from "sonner"
import ErrorBanner from "@/components/ui/error-banner"
import { Button } from "@/components/ui/button"
import SelectedTransactionDetails from "./SelectedTransactionDetails"
import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements"
import { Form } from "@/components/ui/form"
import { useCallback, useContext, useMemo, useRef, useState } from "react"
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 { flt, formatCurrency } from "@/lib/numbers"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import SelectedTransactionsTable from "./SelectedTransactionsTable"
import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount"
import { BankTransaction } from "@/types/Accounts/BankTransaction"
import FileUploadBanner from "@/components/common/FileUploadBanner"
import { Label } from "@/components/ui/label"
import { FileDropzone } from "@/components/ui/file-dropzone"
import { useGetAccounts } from "@/components/common/AccountsDropdown"
import { useHotkeys } from "react-hotkeys-hook"
const RecordBankEntryModalContent = () => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
return <div className='p-4'>
<span className='text-center'>{_("No transaction selected")}</span>
</div>
}
if (selectedTransaction.length === 1) {
return <BankEntryForm
selectedTransaction={selectedTransaction[0]} />
}
return <BulkBankEntryForm
selectedTransactions={selectedTransaction}
/>
}
const BulkBankEntryForm = ({ selectedTransactions }: { selectedTransactions: UnreconciledTransaction[] }) => {
const form = useForm<{
account: string
}>({
defaultValues: {
account: ''
}
})
const { call, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_bank_entry_and_reconcile')
const onReconcile = useRefreshUnreconciledTransactions()
const addToActionLog = useUpdateActionLog()
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
const onSubmit = (data: { account: string }) => {
call({
bank_transactions: selectedTransactions.map(transaction => transaction.name),
account: data.account
}).then(({ message }) => {
addToActionLog({
type: 'bank_entry',
timestamp: (new Date()).getTime(),
isBulk: true,
items: message.map((item) => ({
bankTransaction: item.transaction,
voucher: {
reference_doctype: "Journal Entry",
reference_name: item.journal_entry.name,
doc: item.journal_entry,
posting_date: item.journal_entry.posting_date,
}
})),
bulkCommonData: {
account: data.account,
}
})
toast.success(_("Bank Entries Created"), {
duration: 4000,
})
// Set this to the last selected transaction
onReconcile(selectedTransactions[selectedTransactions.length - 1])
setIsOpen(false)
})
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-4">
{error && <ErrorBanner error={error} />}
<SelectedTransactionsTable />
<div className="grid grid-cols-3 gap-4">
<AccountFormField
name='account'
filterFunction={(acc) => {
// Do not allow payable and receivable accounts
return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable'
}}
label={_('Account')}
isRequired
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
interface BankEntryFormData extends Pick<JournalEntry, 'voucher_type' | 'cheque_date' | 'posting_date' | 'cheque_no' | 'user_remark'> {
entries: JournalEntry['accounts']
}
const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: UnreconciledTransaction }) => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
const onClose = () => {
setIsOpen(false)
}
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
const defaultAccounts = useMemo(() => {
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
const accounts: Partial<JournalEntryAccount>[] = [
{
account: selectedBankAccount?.account ?? '',
bank_account: selectedTransaction.bank_account,
// Bank is debited if it's a deposit
debit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
credit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
party_type: '',
party: '',
cost_center: ''
}]
// If there is no rule, we can just add the entries for the bank account transaction and the other side will be the reverse
if (!rule) {
accounts.push(
{
account: '',
// Amounts will be the reverse of the bank account transaction
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
}
)
} else {
// Rule exists, so we need to check the type of rule
if (!rule.bank_entry_type || rule.bank_entry_type === "Single Account") {
// Only a single account needs to be added
accounts.push({
account: rule.account ?? '',
// Amounts will be the reverse of the bank account transaction
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
})
} else {
// For multiple accounts, we need to loop over and add entries for each
// The last row will just be the remaining amount
let hasTotallyEmptyRowEarlier = false;
let totalDebits = isWithdrawal ? 0 : selectedTransaction.unallocated_amount ?? 0
let totalCredits = isWithdrawal ? selectedTransaction.unallocated_amount ?? 0 : 0
for (let i = 0; i < (rule.accounts?.length ?? 0); i++) {
const acc = rule.accounts?.[i]
// If it's the last row, add the difference amount
if (i === (rule.accounts?.length ?? 0) - 1 && !hasTotallyEmptyRowEarlier) {
const differenceAmount = flt(totalDebits - totalCredits, 2)
accounts.push({
account: acc?.account ?? '',
debit: differenceAmount > 0 ? 0 : Math.abs(differenceAmount),
credit: differenceAmount > 0 ? Math.abs(differenceAmount) : 0,
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
user_remark: acc?.user_remark ?? '',
})
} 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;
}
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
totalDebits = flt(totalDebits + computedDebit, 2)
totalCredits = flt(totalCredits + computedCredit, 2)
accounts.push({
account: acc?.account ?? '',
debit: computedDebit,
credit: computedCredit,
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
user_remark: acc?.user_remark ?? '',
})
}
}
}
}
return accounts
}, [rule, selectedTransaction, selectedBankAccount])
const form = useForm<BankEntryFormData>({
defaultValues: {
voucher_type: selectedBankAccount?.is_credit_card ? 'Credit Card Entry' : 'Bank Entry',
cheque_date: selectedTransaction.date,
posting_date: selectedTransaction.date,
cheque_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
user_remark: selectedTransaction.description,
entries: defaultAccounts,
}
})
const onReconcile = useRefreshUnreconciledTransactions()
const { call: createBankEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bank_entry_and_reconcile')
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
const addToActionLog = useUpdateActionLog()
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
const [isUploading, setIsUploading] = useState(false)
const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress()
const [files, setFiles] = useState<File[]>([])
const onSubmit = (data: BankEntryFormData) => {
createBankEntry({
bank_transaction_name: selectedTransaction.name,
...data
}).then(async ({ message }) => {
addToActionLog({
type: 'bank_entry',
isBulk: false,
timestamp: (new Date()).getTime(),
items: [
{
bankTransaction: message.transaction,
voucher: {
reference_doctype: "Journal Entry",
reference_name: message.journal_entry.name,
reference_no: message.journal_entry.cheque_no,
reference_date: message.journal_entry.cheque_date,
posting_date: message.journal_entry.posting_date,
doc: message.journal_entry,
}
}
]
})
toast.success(_("Bank Entry Created"), {
duration: 4000,
closeButton: true,
action: {
label: _("Undo"),
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
},
actionButtonStyle: {
backgroundColor: "rgb(0, 138, 46)"
}
})
if (files.length > 0) {
setIsUploading(true)
startTracking(files.length)
const uploadPromises = files.map((f, fileIndex) => {
return frappeFile.uploadFile(f, {
isPrivate: true,
doctype: "Journal Entry",
docname: message.journal_entry.name,
}, (_bytesUploaded, _totalBytes, progress) => {
updateFileProgress(fileIndex, progress?.progress ?? 0)
})
})
return Promise.all(uploadPromises).then(() => {
resetProgress()
setIsUploading(false)
}).catch((error) => {
console.error(error)
toast.error(_("Error uploading attachments"), {
duration: 4000,
})
resetProgress()
setIsUploading(false)
})
} else {
return Promise.resolve()
}
}).then(() => {
onReconcile(selectedTransaction)
onClose()
})
}
useHotkeys('meta+s', () => {
form.handleSubmit(onSubmit)()
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: true
})
if (isUploading && isCompleted) {
return <FileUploadBanner uploadProgress={uploadProgress} />
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<div className='grid grid-cols-2 gap-4'>
<SelectedTransactionDetails transaction={selectedTransaction} />
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-2 gap-4'>
<DateField
name='posting_date'
label={_("Posting Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
<DateField
name='cheque_date'
label={_("Reference Date")}
isRequired
inputProps={{ autoFocus: false }}
rules={{
required: _("Reference Date is required"),
}}
/>
</div>
<DataField name='cheque_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }}
rules={{
required: _("Reference is required"),
}} />
</div>
</div>
<div>
<Entries company={selectedTransaction.company ?? ''} isWithdrawal={isWithdrawal} currency={selectedTransaction.currency ?? getCompanyCurrency(selectedTransaction.company ?? '')} />
</div>
<div className='flex flex-col gap-2'>
<div className='grid grid-cols-2 gap-4'>
<SmallTextField
name='user_remark'
label={_("Remarks")}
/>
<div
data-slot="form-item"
className="flex flex-col gap-2"
>
<Label>{_("Attachments")}</Label>
<FileDropzone files={files} setFiles={setFiles} />
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => {
const { getValues, setValue, control } = useFormContext<BankEntryFormData>()
const { call } = useContext(FrappeContext) as FrappeConfig
const partyMapRef = useRef<Record<string, string>>({})
const onPartyChange = (value: string, index: number) => {
// Get the account for the party type
if (value) {
if (partyMapRef.current[value]) {
setValue(`entries.${index}.account`, partyMapRef.current[value])
} else {
call.get('erpnext.accounts.party.get_party_account', {
party: value,
party_type: getValues(`entries.${index}.party_type`),
company: company
}).then((result: { message: string }) => {
setValue(`entries.${index}.account`, result.message)
partyMapRef.current[value] = result.message
})
}
} else {
setValue(`entries.${index}.account`, '')
}
}
const { data: accounts } = useGetAccounts()
const onAccountChange = (value: string, index: number) => {
// If it's an income or expense account, get the default cost center
if (value) {
const account = accounts?.find((acc) => acc.name === value)
if (account && account.report_type === "Profit and Loss") {
// Set the default company cost center
setValue(`entries.${index}.cost_center`, getCompanyCostCenter(company) ?? '')
return
}
}
setValue(`entries.${index}.cost_center`, '')
}
const { fields, append, remove } = useFieldArray({
control: control,
name: 'entries'
})
const onAdd = useCallback(() => {
const existingEntries = getValues('entries')
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
const remainingAmount = flt(totalDebits - totalCredits, 2)
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
append({
party_type: '',
party: '',
account: '',
debit: debitAmount,
credit: creditAmount,
cost_center: getCompanyCostCenter(company) ?? ''
} as JournalEntryAccount, {
focusName: `entries.${existingEntries.length}.account`
})
}, [company, append, getValues])
const [selectedRows, setSelectedRows] = useState<number[]>([])
const onSelectRow = useCallback((index: number) => {
setSelectedRows(prev => {
if (prev.includes(index)) {
return prev.filter(i => i !== index)
}
return [...prev, index]
})
}, [])
const onSelectAll = useCallback(() => {
setSelectedRows(prev => {
if (prev.length === fields.length) {
return []
}
return [...fields.map((_, index) => index)]
})
}, [fields])
const onRemove = useCallback(() => {
// Do not remove the first row
remove(selectedRows.filter(index => index !== 0))
setSelectedRows([])
}, [remove, selectedRows])
/**
* When add difference is clicked, check if the last row has nothing filled in.
* If last row is empty (no debit or credit), then set that row's amount. Else, add a new row with the difference amount.
*/
const onAddDifferenceClicked = () => {
const existingEntries = getValues('entries')
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
const lastIndex = existingEntries.length - 1
const isLastRowEmpty = (existingEntries[lastIndex]?.debit === 0 || existingEntries[lastIndex]?.debit === undefined) && (existingEntries[lastIndex]?.credit === 0 || existingEntries[lastIndex]?.credit === undefined)
const remainingAmount = flt(totalDebits - totalCredits, 2)
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
if (isLastRowEmpty) {
setValue(`entries.${lastIndex}.debit`, debitAmount)
setValue(`entries.${lastIndex}.credit`, creditAmount)
} else {
append({
party_type: '',
party: '',
account: '',
debit: debitAmount,
credit: creditAmount,
cost_center: getCompanyCostCenter(company) ?? ''
} as JournalEntryAccount, {
focusName: `entries.${existingEntries.length}.account`
})
}
}
return <div className="flex flex-col gap-2">
<Table>
<TableHeader>
<TableRow>
<TableHead><Checkbox
disabled={fields.length === 0}
// Make this accessible to screen readers
aria-label={_("Select all")}
checked={selectedRows.length > 0 && selectedRows.length === fields.length}
onCheckedChange={onSelectAll} /></TableHead>
<TableHead>{_("Party")}</TableHead>
<TableHead>{_("Account")}</TableHead>
<TableHead>{_("Cost Center")}</TableHead>
<TableHead>{_("Remarks")}</TableHead>
<TableHead className="text-end">{_("Debit")}</TableHead>
<TableHead className="text-end">{_("Credit")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, index) => (
<TableRow key={field.id} className={index === 0 ? 'bg-surface-gray-1 cursor-not-allowed' : ''} title={index === 0 ? _("This is the bank account entry. You cannot edit it.") : ''}>
<TableCell>
<Checkbox
checked={selectedRows.includes(index)}
onCheckedChange={() => onSelectRow(index)}
// Make this accessible to screen readers
aria-label={_("Select row {0}", [String(index + 1)])}
disabled={index === 0}
/>
</TableCell>
<TableCell className="align-top">
<div className="flex">
<PartyTypeFormField
name={`entries.${index}.party_type`}
label={_("Party Type")}
isRequired
readOnly={index === 0}
hideLabel
inputProps={{
type: isWithdrawal ? 'Payable' : 'Receivable',
triggerProps: {
className: 'rounded-e-none',
tabIndex: -1
},
readOnly: index === 0,
}} />
<PartyField index={index} onChange={onPartyChange} readOnly={index === 0} />
</div>
</TableCell>
<TableCell className="align-top">
<AccountFormField
name={`entries.${index}.account`}
label={_("Account")}
rules={{
required: _("Account is required"),
onChange: (event) => {
onAccountChange(event.target.value, index)
}
}}
buttonClassName="min-w-64"
readOnly={index === 0}
isRequired
hideLabel
/>
</TableCell>
<TableCell className="align-top">
<LinkFormField
doctype="Cost Center"
name={`entries.${index}.cost_center`}
label={_("Cost Center")}
filters={[["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]}
buttonClassName="min-w-48"
readOnly={index === 0}
hideLabel
/>
</TableCell>
<TableCell className="align-top">
<DataField
name={`entries.${index}.user_remark`}
label={_("Remarks")}
readOnly={index === 0}
inputProps={{
placeholder: _("e.g. Bank Charges"),
className: 'min-w-64',
readOnly: index === 0
}}
hideLabel
/>
</TableCell>
<TableCell className={cn("text-end align-top")}>
<CurrencyFormField
name={`entries.${index}.debit`}
label={_("Debit")}
isRequired
hideLabel
readOnly={index === 0}
style={index === 0 ? !isWithdrawal ? {
color: "var(--color-ink-gray-8)",
} : {} : {}}
currency={currency}
leftSlot={index === 0 && !isWithdrawal ? <Tooltip>
<TooltipTrigger asChild><ArrowDownRight className="text-ink-green-3" /></TooltipTrigger>
<TooltipContent>{_("Bank account debit for deposit")}</TooltipContent>
</Tooltip> : undefined}
/>
</TableCell>
<TableCell className={cn("text-end align-top")}>
<CurrencyFormField
name={`entries.${index}.credit`}
style={index === 0 && isWithdrawal ? {
color: "var(--color-ink-gray-8)",
} : {}}
label={_("Credit")}
isRequired
hideLabel
readOnly={index === 0}
currency={currency}
leftSlot={index === 0 && isWithdrawal ? <Tooltip>
<TooltipTrigger asChild><ArrowUpRight className="text-ink-red-3" /></TooltipTrigger>
<TooltipContent>{_("Bank account credit for withdrawal")}</TooltipContent>
</Tooltip> : undefined}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex justify-between gap-2">
<div className="flex gap-2 justify-end">
<div>
<Button size='sm' type='button' variant={'outline'} onClick={onAdd}><Plus /> {_("Add Row")}</Button>
</div>
{selectedRows.length > 0 && <div>
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
</div>}
</div>
<Summary currency={currency} addRow={onAddDifferenceClicked} />
</div>
</div>
}
const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => {
const { control } = useFormContext<BankEntryFormData>()
const party_type = useWatch({
control,
name: `entries.${index}.party_type`
})
if (!party_type) {
return <DataField
name={`entries.${index}.party`}
label={_("Party")}
isRequired
inputProps={{
disabled: true,
className: 'rounded-s-none border-s-0 min-w-64'
}}
hideLabel
/>
}
return <LinkFormField
name={`entries.${index}.party`}
label={_("Party")}
rules={{
onChange: (event) => {
onChange(event.target.value, index)
},
}}
hideLabel
readOnly={readOnly}
buttonClassName="rounded-s-none border-s-0 min-w-64"
doctype={party_type}
/>
}
const Summary = ({ currency, addRow }: { currency: string, addRow: () => void }) => {
const { control } = useFormContext<BankEntryFormData>()
const entries = useWatch({ control, name: 'entries' })
const { total, totalCredits, totalDebits } = useMemo(() => {
// Do a total debits - total credits
const totalDebits = entries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
const totalCredits = entries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
return { total: flt(totalDebits - totalCredits, 2), totalDebits, totalCredits }
}, [entries])
const onAddRow = useCallback(() => {
addRow()
}, [addRow])
const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => {
return <span className={cn("w-32 text-end font-medium text-sm font-numeric", className)}>{children}</span>
}
return <div className="flex flex-col gap-2 items-end">
<div className="flex gap-2 justify-between">
<TextComponent>{_("Total Debit")}</TextComponent>
<TextComponent>{formatCurrency(totalDebits, currency)}</TextComponent>
</div>
<div className="flex gap-2 justify-between">
<TextComponent>{_("Total Credit")}</TextComponent>
<TextComponent>{formatCurrency(totalCredits, currency)}</TextComponent>
</div>
{total !== 0 && <div className="flex gap-2 justify-between">
<TextComponent>{_("Difference")}</TextComponent>
<Tooltip>
<TooltipTrigger asChild>
<Button type='button' variant='link' className="p-0 text-ink-red-3 underline h-fit" role='button' onClick={onAddRow}>
<TextComponent className='text-ink-red-3'>{formatCurrency(total, currency)}</TextComponent>
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Add a row with the difference amount")}
</TooltipContent>
</Tooltip>
</div>}
</div>
}
export default RecordBankEntryModalContent

View File

@@ -76,7 +76,7 @@ const BankReconciliationStatementView = () => {
toast.success(_("Copied to clipboard"))
})
},
[copyToClipboard, _],
[copyToClipboard],
)
const statementColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
@@ -181,7 +181,7 @@ const BankReconciliationStatementView = () => {
cell: ({ row }) => formatDate(row.original.clearance_date),
},
],
[_, onCopy],
[onCopy],
)
const statementRows = useMemo(() => {

View File

@@ -176,7 +176,7 @@ const BankTransactionListView = () => {
),
},
],
[_, accountCurrency, onUndo],
[accountCurrency, onUndo],
)
const [search, setSearch] = useDebounceValue('', 250)

View File

@@ -1,125 +1,52 @@
import { AlertDialog, AlertDialogOverlay, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog"
import { useAtom, useAtomValue } from "jotai"
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useMemo } from "react"
import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
import { BankTransaction } from "@/types/Accounts/BankTransaction"
import { toast } from "sonner"
import ErrorBanner from "@/components/ui/error-banner"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { formatCurrency } from "@/lib/numbers"
import { Badge } from "@/components/ui/badge"
import { slug } from "@/lib/frappe"
import SelectedTransactionDetails from "./SelectedTransactionDetails"
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useAtom } from "jotai"
import { Loader2Icon } from "lucide-react"
import { lazy, Suspense } from "react"
import { bankRecUnreconcileModalAtom } from "./bankRecAtoms"
import _ from "@/lib/translate"
const BankTransactionUnreconcileModalBody = lazy(() => import('./BankTransactionUnreconcileModalBody'))
const BankTransactionUnreconcileModalFallback = () => (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
)
const BankTransactionUnreconcileModal = () => {
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
const onOpenChange = (v: boolean) => {
if (!v) {
setBankRecUnreconcileModal('')
}
}
return <AlertDialog open={!!unreconcileModal} onOpenChange={onOpenChange}>
<AlertDialogOverlay />
<AlertDialogContent className="min-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
<AlertDialogDescription>
{_("Are you sure you want to unreconcile this transaction?")}
</AlertDialogDescription>
</AlertDialogHeader>
<BankTransactionUnreconcileModalContent />
</AlertDialogContent>
</AlertDialog>
const onOpenChange = (v: boolean) => {
if (!v) {
setBankRecUnreconcileModal('')
}
}
if (!unreconcileModal) {
return null
}
return (
<AlertDialog open onOpenChange={onOpenChange}>
<AlertDialogContent className="min-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
<AlertDialogDescription>
{_("Are you sure you want to unreconcile this transaction?")}
</AlertDialogDescription>
</AlertDialogHeader>
<Suspense fallback={<BankTransactionUnreconcileModalFallback />}>
<BankTransactionUnreconcileModalBody />
</Suspense>
</AlertDialogContent>
</AlertDialog>
)
}
const BankTransactionUnreconcileModalContent = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const { mutate } = useSWRConfig()
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
const { data: transaction, error } = useFrappeGetDoc<BankTransaction>('Bank Transaction', unreconcileModal)
const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction')
const onUnreconcile = (event: React.MouseEvent<HTMLButtonElement>) => {
call({
transaction_name: unreconcileModal
}).then(() => {
// Mutate the transactions list, unreconciled transactions list and account closing balance
mutate(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`)
toast.success(_("Transaction Unreconciled"))
setBankRecUnreconcileModal('')
})
event.preventDefault()
}
const vouchersWhichWillBeCancelled = useMemo(() => {
return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created')
}, [transaction])
return <div>
<div className="flex flex-col gap-3">
{error && <ErrorBanner error={error} />}
{unreconcileError && <ErrorBanner error={unreconcileError} />}
{transaction && <SelectedTransactionDetails transaction={transaction} />}
<span className="font-medium text-sm">{_("This transaction has been reconciled with the following document(s):")}</span>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Document")}</TableHead>
<TableHead>{_("Amount")}</TableHead>
<TableHead>{_("Reconciliation Type")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transaction?.payment_entries?.map((voucher) => {
return <TableRow key={voucher.name}>
<TableCell>
<a className="underline underline-offset-4"
target="_blank"
rel="noopener noreferrer"
href={`/desk/${slug(voucher.payment_document as string)}/${voucher.payment_entry}`}
>
{`${_(voucher.payment_document)}: ${voucher.payment_entry}`}
</a>
</TableCell>
<TableCell>{formatCurrency(voucher.allocated_amount)}</TableCell>
<TableCell>{voucher.reconciliation_type === 'Voucher Created' ?
<Badge theme="green">{_(voucher.reconciliation_type)}</Badge> :
<Badge theme="blue">{_(voucher.reconciliation_type ?? "Matched")}</Badge>}</TableCell>
</TableRow>
})}
</TableBody>
</Table>
<div className="py-4">
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && <span>The following documents will be <strong>cancelled</strong>:</span>}
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && <ol className="ms-6 list-disc [&>li]:mt-2">
{vouchersWhichWillBeCancelled?.map((voucher) => {
return <li key={voucher.name}>{_(voucher.payment_document)}: {voucher.payment_entry}</li>
})}
</ol>}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>{_("Cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onUnreconcile} theme="red" disabled={loading}>
{_("Unreconcile")}
</AlertDialogAction>
</AlertDialogFooter>
</div>
}
export default BankTransactionUnreconcileModal
export default BankTransactionUnreconcileModal

View File

@@ -0,0 +1,109 @@
import { AlertDialogAction, AlertDialogCancel, AlertDialogFooter } from "@/components/ui/alert-dialog"
import { useAtom, useAtomValue } from "jotai"
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useMemo } from "react"
import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
import { BankTransaction } from "@/types/Accounts/BankTransaction"
import { toast } from "sonner"
import ErrorBanner from "@/components/ui/error-banner"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { formatCurrency } from "@/lib/numbers"
import { Badge } from "@/components/ui/badge"
import { slug } from "@/lib/frappe"
import SelectedTransactionDetails from "./SelectedTransactionDetails"
import _ from "@/lib/translate"
const BankTransactionUnreconcileModalBody = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const { mutate } = useSWRConfig()
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
const { data: transaction, error, isLoading } = useFrappeGetDoc<BankTransaction>('Bank Transaction', unreconcileModal)
const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction')
const onUnreconcile = (event: React.MouseEvent<HTMLButtonElement>) => {
call({
transaction_name: unreconcileModal
}).then(() => {
mutate(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`)
toast.success(_("Transaction Unreconciled"))
setBankRecUnreconcileModal('')
})
event.preventDefault()
}
const vouchersWhichWillBeCancelled = useMemo(() => {
return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created')
}, [transaction])
return (
<>
<div className="flex flex-col gap-3">
{error && <ErrorBanner error={error} />}
{unreconcileError && <ErrorBanner error={unreconcileError} />}
{transaction && <SelectedTransactionDetails transaction={transaction} />}
<span className="font-medium text-sm">{_("This transaction has been reconciled with the following document(s):")}</span>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Document")}</TableHead>
<TableHead>{_("Amount")}</TableHead>
<TableHead>{_("Reconciliation Type")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transaction?.payment_entries?.map((voucher) => {
return (
<TableRow key={voucher.name}>
<TableCell>
<a
className="underline underline-offset-4"
target="_blank"
rel="noopener noreferrer"
href={`/desk/${slug(voucher.payment_document as string)}/${voucher.payment_entry}`}
>
{`${_(voucher.payment_document)}: ${voucher.payment_entry}`}
</a>
</TableCell>
<TableCell>{formatCurrency(voucher.allocated_amount)}</TableCell>
<TableCell>
{voucher.reconciliation_type === 'Voucher Created' ?
<Badge theme="green">{_(voucher.reconciliation_type)}</Badge> :
<Badge theme="blue">{_(voucher.reconciliation_type ?? "Matched")}</Badge>}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
<div className="py-4">
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && (
<span>The following documents will be <strong>cancelled</strong>:</span>
)}
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && (
<ol className="ms-6 list-disc [&>li]:mt-2">
{vouchersWhichWillBeCancelled?.map((voucher) => {
return <li key={voucher.name}>{_(voucher.payment_document)}: {voucher.payment_entry}</li>
})}
</ol>
)}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>{_("Cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onUnreconcile} theme="red" disabled={loading || isLoading}>
{_("Unreconcile")}
</AlertDialogAction>
</AlertDialogFooter>
</>
)
}
export default BankTransactionUnreconcileModalBody

View File

@@ -91,7 +91,7 @@ const IncorrectlyClearedEntriesView = () => {
})
})
},
[clearClearingDate, mutate, _],
[clearClearingDate, mutate],
)
const accountCurrency = useMemo(
@@ -174,7 +174,7 @@ const IncorrectlyClearedEntriesView = () => {
),
},
],
[_, accountCurrency, onClearClick],
[accountCurrency, onClearClick],
)
return <div className="space-y-4 py-2">

View File

@@ -14,7 +14,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
import { Button } from "@/components/ui/button"
import CurrencyInput from 'react-currency-input-field'
import { getCurrencySymbol } from "@/lib/currency"
import { Virtuoso } from 'react-virtuoso'
import { useVirtualizer } from '@tanstack/react-virtual'
import { formatDate } from "@/lib/date"
import { Badge } from "@/components/ui/badge"
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
@@ -22,10 +22,10 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/comp
import { Skeleton } from "@/components/ui/skeleton"
import { slug } from "@/lib/frappe"
import _ from "@/lib/translate"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import TransferModal from "./TransferModal"
import BankEntryModal from "./BankEntryModal"
import RecordPaymentModal from "./RecordPaymentModal"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import SelectedTransactionsTable from "./SelectedTransactionsTable"
import MatchFilters from "./MatchFilters"
import { useHotkeys } from "react-hotkeys-hook"
@@ -69,6 +69,59 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
</>
}
/** TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets the real height. */
function VirtualizedListBody<T>({
items,
height,
getItemKey,
children,
estimateSize = 74,
}: {
items: T[]
height: number
getItemKey: (item: T, index: number) => string | number
children: (item: T, index: number) => React.ReactNode
estimateSize?: number
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => estimateSize,
overscan: 8,
getItemKey: (index) => String(getItemKey(items[index], index)),
})
if (items.length === 0) {
return null
}
return (
<div
ref={scrollRef}
className="overflow-auto contain-strict"
style={{ height }}
>
<div
className="relative w-full"
style={{ height: rowVirtualizer.getTotalSize() }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
{children(items[virtualRow.index], virtualRow.index)}
</div>
))}
</div>
</div>
)
}
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
@@ -134,6 +187,7 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
}
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
const listHeight = contentHeight - 72
if (isLoading) {
return <UnreconciledTransactionsLoadingState />
@@ -222,14 +276,14 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
text={hasFilters ? _("No transactions found for the given filters.") : _("No unreconciled transactions found")}
description={hasFilters ? _("Try adjusting your search or filter criteria.") : _("Import your bank statement to get started.")} />}
<Virtuoso
data={results}
itemContent={(_index, transaction) => (
<UnreconciledTransactionItem transaction={transaction} />
)}
style={{ minHeight: Math.max(contentHeight - 80, 400) }}
totalCount={results?.length}
/>
<VirtualizedListBody
items={results}
height={listHeight}
estimateSize={74}
getItemKey={(transaction) => transaction.name}
>
{(transaction) => <UnreconciledTransactionItem transaction={transaction} />}
</VirtualizedListBody>
</div>
}
@@ -559,11 +613,8 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom)
const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
if (!rule) {
return null
}
const getActionIcon = () => {
if (!rule) return null
switch (rule.classify_as) {
case "Bank Entry":
return <Landmark />
@@ -577,6 +628,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
}
const getActionStyles = () => {
if (!rule) return {}
switch (rule.classify_as) {
case "Bank Entry":
return {
@@ -610,6 +662,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
}
const handleActionClick = () => {
if (!rule) return
switch (rule.classify_as) {
case "Bank Entry":
setRecordJournalEntryModalOpen(true)
@@ -624,6 +677,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
}
const getActionDescription = () => {
if (!rule) return ""
switch (rule.classify_as) {
case "Bank Entry":
return _("Create a journal entry for expenses, income or split transactions")
@@ -636,8 +690,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
}
}
useHotkeys('meta+r', () => {
//
useHotkeys('alt+r', () => {
handleActionClick()
}, {
enabled: true,
@@ -647,6 +700,10 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
const styles = getActionStyles()
if (!rule) {
return null
}
return (
<Card className={`border ${styles.border} ${styles.bg} shadow-sm hover:shadow-md transition-all duration-200`}>
<CardHeader className="pb-0">
@@ -721,6 +778,9 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
const voucherList = vouchers?.message ?? []
const listHeight = contentHeight - 120
if (error) {
return <ErrorBanner error={error} />
}
@@ -747,7 +807,7 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
<span>or</span>
<Separator className="flex-1" />
</div>
{vouchers?.message.length === 0 && <Empty className="my-4">
{voucherList.length === 0 && <Empty className="my-4">
<EmptyMedia>
<ReceiptIcon />
</EmptyMedia>
@@ -756,14 +816,14 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
<EmptyTitle>{_("No vouchers found for this transaction")}</EmptyTitle>
</EmptyHeader>
</Empty>}
<Virtuoso
data={vouchers?.message}
itemContent={(index, voucher) => (
<VoucherItem voucher={voucher} index={index} />
)}
style={{ height: contentHeight }}
totalCount={vouchers?.message.length}
/>
<VirtualizedListBody
items={voucherList}
height={listHeight}
estimateSize={121}
getItemKey={(voucher) => voucher.name}
>
{(voucher, index) => <VoucherItem voucher={voucher} index={index} />}
</VirtualizedListBody>
</div >
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,555 +1,32 @@
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogClose, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { useAtom } from 'jotai'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { ModalContentFallback } from '@/components/ui/modal-content-fallback'
import _ from '@/lib/translate'
import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils'
import { Button } from '@/components/ui/button'
import SelectedTransactionDetails from './SelectedTransactionDetails'
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
import { useForm, useFormContext, useWatch } from 'react-hook-form'
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk'
import { toast } from 'sonner'
import ErrorBanner from '@/components/ui/error-banner'
import { H4 } from '@/components/ui/typography'
import { cn } from '@/lib/utils'
import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react'
import { Separator } from '@/components/ui/separator'
import { Form } from '@/components/ui/form'
import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements'
import SelectedTransactionsTable from './SelectedTransactionsTable'
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
import { formatDate } from '@/lib/date'
import { useContext, useMemo, useState } from 'react'
import { formatCurrency } from '@/lib/numbers'
import { Label } from '@/components/ui/label'
import { FileDropzone } from '@/components/ui/file-dropzone'
import FileUploadBanner from '@/components/common/FileUploadBanner'
import { BankTransaction } from '@/types/Accounts/BankTransaction'
import { useHotkeys } from 'react-hotkeys-hook'
import { useDirection } from '@/components/ui/direction'
import BankLogo from '@/components/common/BankLogo'
import { lazy, Suspense } from 'react'
import { bankRecTransferModalAtom } from './bankRecAtoms'
const TransferModalContent = lazy(() => import('./TransferModalContent'))
const TransferModal = () => {
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className='min-w-7xl'>
<DialogHeader>
<DialogTitle>{_("Transfer")}</DialogTitle>
<DialogDescription>
{_("Record an internal transfer to another bank/credit card/cash account.")}
</DialogDescription>
</DialogHeader>
<TransferModalContent />
</DialogContent>
</Dialog>
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className='min-w-7xl'>
<DialogHeader>
<DialogTitle>{_("Transfer")}</DialogTitle>
<DialogDescription>
{_("Record an internal transfer to another bank/credit card/cash account.")}
</DialogDescription>
</DialogHeader>
{isOpen && (
<Suspense fallback={<ModalContentFallback />}>
<TransferModalContent />
</Suspense>
)}
</DialogContent>
</Dialog>
)
}
const TransferModalContent = () => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
return <div className='p-4'>
<span className='text-center'>{_("No transaction selected")}</span>
</div>
}
if (selectedTransaction.length === 1) {
return <InternalTransferForm
selectedBankAccount={selectedBankAccount}
selectedTransaction={selectedTransaction[0]} />
}
return <BulkInternalTransferForm transactions={selectedTransaction} />
}
const BulkInternalTransferForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => {
const form = useForm<{
bank_account: string
}>()
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_internal_transfer')
const onReconcile = useRefreshUnreconciledTransactions()
const addToActionLog = useUpdateActionLog()
const onSubmit = (data: { bank_account: string }) => {
createPaymentEntry({
bank_transaction_names: transactions.map((transaction) => transaction.name),
bank_account: data.bank_account
}).then(({ message }) => {
addToActionLog({
type: 'transfer',
timestamp: (new Date()).getTime(),
isBulk: true,
items: message.map((item) => ({
bankTransaction: item.transaction,
voucher: {
reference_doctype: "Payment Entry",
reference_name: item.payment_entry.name,
posting_date: item.payment_entry.posting_date,
doc: item.payment_entry,
}
})),
bulkCommonData: {
bank_account: data.bank_account,
}
})
toast.success(_("Transfer Recorded"), {
duration: 4000,
closeButton: true,
})
onReconcile(transactions[transactions.length - 1])
setIsOpen(false)
})
}
const onAccountChange = (account: string) => {
form.setValue('bank_account', account)
}
const selectedAccount = useWatch({ control: form.control, name: 'bank_account' })
const currentCompany = useCurrentCompany()
const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '')
console.log("This is here", transactions)
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<SelectedTransactionsTable />
<BankOrCashPicker company={company} bankAccount={transactions[0]?.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
interface InternalTransferFormFields extends PaymentEntry {
mirror_transaction_name?: string
}
const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => {
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
const onClose = () => {
setIsOpen(false)
}
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
const form = useForm<InternalTransferFormFields>({
defaultValues: {
payment_type: 'Internal Transfer',
company: selectedTransaction?.company,
// If the transaction is a withdrawal, set the paid from to the selected bank account
paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
// If the transaction is a deposit, set the paid to to the selected bank account
paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
// Set the amount to the amount of the selected transaction
paid_amount: selectedTransaction.unallocated_amount,
received_amount: selectedTransaction.unallocated_amount,
reference_date: selectedTransaction.date,
posting_date: selectedTransaction.date,
reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
}
})
const onReconcile = useRefreshUnreconciledTransactions()
const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer')
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
const addToActionLog = useUpdateActionLog()
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
const [isUploading, setIsUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [files, setFiles] = useState<File[]>([])
const onSubmit = (data: InternalTransferFormFields) => {
createPaymentEntry({
bank_transaction_name: selectedTransaction.name,
...data,
custom_remarks: data.remarks ? true : false,
// Pass this to reconcile both at the same time
mirror_transaction_name: data.mirror_transaction_name
}).then(async ({ message }) => {
addToActionLog({
type: 'transfer',
timestamp: (new Date()).getTime(),
isBulk: false,
items: [
{
bankTransaction: message.transaction,
voucher: {
reference_doctype: "Payment Entry",
reference_name: message.payment_entry.name,
reference_no: message.payment_entry.reference_no,
reference_date: message.payment_entry.reference_date,
posting_date: message.payment_entry.posting_date,
doc: message.payment_entry,
}
}
]
})
toast.success(_("Transfer Recorded"), {
duration: 4000,
closeButton: true,
action: {
label: _("Undo"),
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
},
actionButtonStyle: {
backgroundColor: "rgb(0, 138, 46)"
}
})
if (files.length > 0) {
setIsUploading(true)
const uploadPromises = files.map(f => {
return frappeFile.uploadFile(f, {
isPrivate: true,
doctype: "Payment Entry",
docname: message.payment_entry.name,
}, (_bytesUploaded, _totalBytes, progress) => {
setUploadProgress((currentProgress) => {
//If there are multiple files, we need to add the progress to the current progress
return currentProgress + ((progress?.progress ?? 0) / files.length)
})
})
})
return Promise.all(uploadPromises).then(() => {
setUploadProgress(0)
setIsUploading(false)
})
} else {
return Promise.resolve()
}
}).then(() => {
setUploadProgress(0)
setIsUploading(false)
onReconcile(selectedTransaction)
onClose()
})
}
useHotkeys('meta+s', () => {
form.handleSubmit(onSubmit)()
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: true
})
const onAccountChange = (account: string, is_mirror: boolean = false) => {
//If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into
if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) {
form.setValue('paid_to', account)
} else {
form.setValue('paid_from', account)
}
if (!is_mirror) {
// Reset the mirror transaction name
form.setValue('mirror_transaction_name', '')
}
}
const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' })
const direction = useDirection()
if (isUploading && isCompleted) {
return <FileUploadBanner uploadProgress={uploadProgress} />
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<div className='grid grid-cols-2 gap-4'>
<SelectedTransactionDetails transaction={selectedTransaction} />
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-2 gap-4'>
<DateField
name='posting_date'
label={_("Posting Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
<DateField
name='reference_date'
label={_("Reference Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
</div>
<DataField name='reference_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }} />
</div>
</div>
<div className='flex flex-col gap-2'>
<H4 className='text-base'>{isWithdrawal ? _('Transferred to') : _('Transferred from')}</H4>
<RecommendedTransferAccount transaction={selectedTransaction} onAccountChange={onAccountChange} />
<BankOrCashPicker company={selectedTransaction.company ?? ''} bankAccount={selectedTransaction.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
</div>
<div className='flex flex-col gap-2 py-2'>
<div className='flex items-end justify-between gap-4'>
<div className='flex-1'>
<AccountFormField
name="paid_from"
label={_("Paid From")}
account_type={['Bank', 'Cash']}
readOnly={isWithdrawal}
filterFunction={(account) => account.name !== selectedBankAccount.account}
isRequired
/>
</div>
<div className='pb-2'>
{direction === 'ltr' ? <ArrowRight /> : <ArrowLeft />}
</div>
<div className='flex-1'>
<AccountFormField
name="paid_to"
label={_("Paid To")}
account_type={['Bank', 'Cash']}
isRequired
readOnly={!isWithdrawal}
filterFunction={(account) => account.name !== selectedBankAccount.account}
/>
</div>
</div>
</div>
<Separator />
<div className='flex flex-col gap-2'>
<div className='grid grid-cols-2 gap-4'>
<SmallTextField
name='remarks'
label={_("Custom Remarks")}
formDescription={_("This will be auto-populated if not set.")}
/>
<div
data-slot="form-item"
className="flex flex-col gap-2"
>
<Label>{_("Attachments")}</Label>
<FileDropzone files={files} setFiles={setFiles} />
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company: string }) => {
const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount)
return <div className='grid grid-cols-4 gap-4'>
{banks.map((bank) => (
<div
className={cn('border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
selectedAccount === bank.account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
)}
role='button'
key={bank.account}
onClick={() => onAccountChange(bank.account ?? '')}
>
<BankLogo bank={bank} iconSize='24px' imageClassName='w-12 h-12' />
<div className='flex flex-col gap-1'>
<span className='font-semibold text-sm'>{bank.account_name} {bank.bank_account_no && <span className='text-xs text-ink-gray-5'>({bank.bank_account_no})</span>}</span>
<span className='text-xs text-ink-gray-5'>{bank.account}</span>
</div>
</div>
))}
<CashPicker company={company ?? ''} selectedAccount={selectedAccount} setSelectedAccount={onAccountChange} />
</div>
}
const CashPicker = ({ company, selectedAccount, setSelectedAccount }: { company: string, selectedAccount: string, setSelectedAccount: (account: string) => void }) => {
const { data } = useFrappeGetCall('frappe.client.get_value', {
doctype: 'Company',
filters: company,
fieldname: 'default_cash_account'
}, undefined, {
revalidateOnFocus: false,
revalidateIfStale: false,
})
const account = data?.message?.default_cash_account
if (account) {
return <div className={cn('border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
selectedAccount === account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
)}
role='button'
onClick={() => setSelectedAccount(account ?? '')}
>
<div className='flex items-center justify-center h-10 w-10'>
<Banknote size='24px' />
</div>
<div className='flex flex-col gap-1'>
<span className='font-semibold text-sm'>Cash</span>
<span className='text-xs text-ink-gray-5'>{data?.message?.default_cash_account}</span>
</div>
</div>
}
return null
}
const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => {
const { setValue, watch } = useFormContext<InternalTransferFormFields>()
const mirrorTransactionName = watch('mirror_transaction_name')
const paid_from = watch('paid_from')
const paid_to = watch('paid_to')
const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', {
transaction_id: transaction.name
}, undefined, {
revalidateOnFocus: false,
revalidateIfStale: false,
})
// Get bank accounts to find the logo
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
if (data?.message?.bank_account && banks) {
return banks.find(bank => bank.name === data.message.bank_account)
}
return null
}, [data?.message?.bank_account, banks])
const selectTransaction = () => {
if (data?.message) {
setValue('mirror_transaction_name', data.message.name)
onAccountChange(data.message.account, true)
}
}
if (data?.message) {
const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0
const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit
const currency = data.message.currency
const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account
const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected
return (<div className='pb-2'>
<div className={cn("flex justify-between items-start gap-3 p-3 border rounded-lg shadow-sm",
isSuggested ? "border-outline-green-4 bg-surface-green-1" : "border-outline-violet-2 bg-surface-violet-2/50")}>
<div>
<div className='flex flex-col gap-3'>
<div className={cn("flex items-center gap-2 shrink-0",
isSuggested ? "text-ink-green-4" : "text-ink-violet-4"
)}>
<BadgeCheck className="w-4 h-4" />
<span className="text-sm font-medium">{_("Suggested Transfer to {0}", [data.message.account])}</span>
</div>
<div className='flex flex-col gap-1'>
<span className='text-p-sm'>{_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])}</span>
<span className='text-p-sm'>{_("Accepting the suggestion will reconcile both transactions.")}</span>
</div>
<div className='flex flex-col gap-1.5'>
<div className='flex items-center gap-1'>
<Calendar size='16px' />
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
</div>
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
</div>
</div>
</div>
<div className='flex flex-col items-end justify-between gap-2 h-full w-[30%]'>
<div className="flex items-center gap-2">
<BankLogo bank={bank} iconSize='24px' imageClassName='h-8 max-w-24' iconClassName={cn(isSuggested ? "text-ink-green-3" : "text-purple-600")} />
</div>
<div className='flex gap-1'>
<div className={cn('flex items-center gap-1 text-end px-0 justify-end py-1 rounded-sm',
isWithdrawal ? 'text-ink-red-3' : 'text-ink-green-3'
)}>
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
<span className='text-sm font-semibold uppercase'>{isWithdrawal ? _('Transferred Out') : _('Received')}</span>
</div>
</div>
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
<div className='pt-1'>
<Button
onClick={selectTransaction}
theme={isSuggested ? "green" : "violet"}
size="md"
type='button'
>
{isSuggested ? <CheckCircle /> : <CheckIcon />}
{isSuggested ? _("Accepted") : _("Use Suggestion")}
</Button>
</div>
</div>
</div>
</div>
)
}
return null
}
export default TransferModal
export default TransferModal

View File

@@ -0,0 +1,530 @@
import { useAtomValue, useSetAtom } from 'jotai'
import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
import { DialogFooter, DialogClose } from '@/components/ui/dialog'
import _ from '@/lib/translate'
import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils'
import { Button } from '@/components/ui/button'
import SelectedTransactionDetails from './SelectedTransactionDetails'
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
import { useForm, useFormContext, useWatch } from 'react-hook-form'
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk'
import { toast } from 'sonner'
import ErrorBanner from '@/components/ui/error-banner'
import { H4 } from '@/components/ui/typography'
import { cn } from '@/lib/utils'
import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react'
import { Separator } from '@/components/ui/separator'
import { Form } from '@/components/ui/form'
import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements'
import SelectedTransactionsTable from './SelectedTransactionsTable'
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
import { useMultiFileUploadProgress } from '@/hooks/useMultiFileUploadProgress'
import { formatDate } from '@/lib/date'
import { useContext, useMemo, useState } from 'react'
import { formatCurrency } from '@/lib/numbers'
import { Label } from '@/components/ui/label'
import { FileDropzone } from '@/components/ui/file-dropzone'
import FileUploadBanner from '@/components/common/FileUploadBanner'
import { BankTransaction } from '@/types/Accounts/BankTransaction'
import { useHotkeys } from 'react-hotkeys-hook'
import { useDirection } from '@/components/ui/direction'
import BankLogo from '@/components/common/BankLogo'
const TransferModalContent = () => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
return <div className='p-4'>
<span className='text-center'>{_("No transaction selected")}</span>
</div>
}
if (selectedTransaction.length === 1) {
return <InternalTransferForm
selectedBankAccount={selectedBankAccount}
selectedTransaction={selectedTransaction[0]} />
}
return <BulkInternalTransferForm transactions={selectedTransaction} />
}
const BulkInternalTransferForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => {
const form = useForm<{
bank_account: string
}>()
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_internal_transfer')
const onReconcile = useRefreshUnreconciledTransactions()
const addToActionLog = useUpdateActionLog()
const onSubmit = (data: { bank_account: string }) => {
createPaymentEntry({
bank_transaction_names: transactions.map((transaction) => transaction.name),
bank_account: data.bank_account
}).then(({ message }) => {
addToActionLog({
type: 'transfer',
timestamp: (new Date()).getTime(),
isBulk: true,
items: message.map((item) => ({
bankTransaction: item.transaction,
voucher: {
reference_doctype: "Payment Entry",
reference_name: item.payment_entry.name,
posting_date: item.payment_entry.posting_date,
doc: item.payment_entry,
}
})),
bulkCommonData: {
bank_account: data.bank_account,
}
})
toast.success(_("Transfer Recorded"), {
duration: 4000,
closeButton: true,
})
onReconcile(transactions[transactions.length - 1])
setIsOpen(false)
})
}
const onAccountChange = (account: string) => {
form.setValue('bank_account', account)
}
const selectedAccount = useWatch({ control: form.control, name: 'bank_account' })
const currentCompany = useCurrentCompany()
const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '')
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<SelectedTransactionsTable />
<BankOrCashPicker company={company} bankAccount={transactions[0]?.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
interface InternalTransferFormFields extends PaymentEntry {
mirror_transaction_name?: string
}
const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => {
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
const onClose = () => {
setIsOpen(false)
}
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
const form = useForm<InternalTransferFormFields>({
defaultValues: {
payment_type: 'Internal Transfer',
company: selectedTransaction?.company,
// If the transaction is a withdrawal, set the paid from to the selected bank account
paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
// If the transaction is a deposit, set the paid to to the selected bank account
paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
// Set the amount to the amount of the selected transaction
paid_amount: selectedTransaction.unallocated_amount,
received_amount: selectedTransaction.unallocated_amount,
reference_date: selectedTransaction.date,
posting_date: selectedTransaction.date,
reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
}
})
const onReconcile = useRefreshUnreconciledTransactions()
const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer')
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
const addToActionLog = useUpdateActionLog()
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
const [isUploading, setIsUploading] = useState(false)
const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress()
const [files, setFiles] = useState<File[]>([])
const onSubmit = (data: InternalTransferFormFields) => {
createPaymentEntry({
bank_transaction_name: selectedTransaction.name,
...data,
custom_remarks: data.remarks ? true : false,
// Pass this to reconcile both at the same time
mirror_transaction_name: data.mirror_transaction_name
}).then(async ({ message }) => {
addToActionLog({
type: 'transfer',
timestamp: (new Date()).getTime(),
isBulk: false,
items: [
{
bankTransaction: message.transaction,
voucher: {
reference_doctype: "Payment Entry",
reference_name: message.payment_entry.name,
reference_no: message.payment_entry.reference_no,
reference_date: message.payment_entry.reference_date,
posting_date: message.payment_entry.posting_date,
doc: message.payment_entry,
}
}
]
})
toast.success(_("Transfer Recorded"), {
duration: 4000,
closeButton: true,
action: {
label: _("Undo"),
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
},
actionButtonStyle: {
backgroundColor: "rgb(0, 138, 46)"
}
})
if (files.length > 0) {
setIsUploading(true)
startTracking(files.length)
const uploadPromises = files.map((f, fileIndex) => {
return frappeFile.uploadFile(f, {
isPrivate: true,
doctype: "Payment Entry",
docname: message.payment_entry.name,
}, (_bytesUploaded, _totalBytes, progress) => {
updateFileProgress(fileIndex, progress?.progress ?? 0)
})
})
return Promise.all(uploadPromises).then(() => {
resetProgress()
setIsUploading(false)
})
} else {
return Promise.resolve()
}
}).then(() => {
resetProgress()
setIsUploading(false)
onReconcile(selectedTransaction)
onClose()
})
}
useHotkeys('meta+s', () => {
form.handleSubmit(onSubmit)()
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: true
})
const onAccountChange = (account: string, is_mirror: boolean = false) => {
//If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into
if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) {
form.setValue('paid_to', account)
} else {
form.setValue('paid_from', account)
}
if (!is_mirror) {
// Reset the mirror transaction name
form.setValue('mirror_transaction_name', '')
}
}
const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' })
const direction = useDirection()
if (isUploading && isCompleted) {
return <FileUploadBanner uploadProgress={uploadProgress} />
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<div className='grid grid-cols-2 gap-4'>
<SelectedTransactionDetails transaction={selectedTransaction} />
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-2 gap-4'>
<DateField
name='posting_date'
label={_("Posting Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
<DateField
name='reference_date'
label={_("Reference Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
</div>
<DataField name='reference_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }} />
</div>
</div>
<div className='flex flex-col gap-2'>
<H4 className='text-base'>{isWithdrawal ? _('Transferred to') : _('Transferred from')}</H4>
<RecommendedTransferAccount transaction={selectedTransaction} onAccountChange={onAccountChange} />
<BankOrCashPicker company={selectedTransaction.company ?? ''} bankAccount={selectedTransaction.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
</div>
<div className='flex flex-col gap-2 py-2'>
<div className='flex items-end justify-between gap-4'>
<div className='flex-1'>
<AccountFormField
name="paid_from"
label={_("Paid From")}
account_type={['Bank', 'Cash']}
readOnly={isWithdrawal}
filterFunction={(account) => account.name !== selectedBankAccount.account}
isRequired
/>
</div>
<div className='pb-2'>
{direction === 'ltr' ? <ArrowRight /> : <ArrowLeft />}
</div>
<div className='flex-1'>
<AccountFormField
name="paid_to"
label={_("Paid To")}
account_type={['Bank', 'Cash']}
isRequired
readOnly={!isWithdrawal}
filterFunction={(account) => account.name !== selectedBankAccount.account}
/>
</div>
</div>
</div>
<Separator />
<div className='flex flex-col gap-2'>
<div className='grid grid-cols-2 gap-4'>
<SmallTextField
name='remarks'
label={_("Custom Remarks")}
formDescription={_("This will be auto-populated if not set.")}
/>
<div
data-slot="form-item"
className="flex flex-col gap-2"
>
<Label>{_("Attachments")}</Label>
<FileDropzone files={files} setFiles={setFiles} />
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company?: string }) => {
const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount)
return <div className='grid grid-cols-4 gap-4'>
{banks.map((bank) => (
<button
className={cn('text-left border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
selectedAccount === bank.account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
)}
type='button'
key={bank.account}
onClick={() => onAccountChange(bank.account ?? '')}
>
<BankLogo bank={bank} iconSize='24px' imageClassName='w-12 h-12' />
<div className='flex flex-col gap-1'>
<span className='font-semibold text-sm'>{bank.account_name} {bank.bank_account_no && <span className='text-xs text-ink-gray-5'>({bank.bank_account_no})</span>}</span>
<span className='text-xs text-ink-gray-5'>{bank.account}</span>
</div>
</button>
))}
<CashPicker company={company ?? ''} selectedAccount={selectedAccount} setSelectedAccount={onAccountChange} />
</div>
}
const CashPicker = ({ company, selectedAccount, setSelectedAccount }: { company: string, selectedAccount: string, setSelectedAccount: (account: string) => void }) => {
const { data } = useFrappeGetCall('frappe.client.get_value', {
doctype: 'Company',
filters: company,
fieldname: 'default_cash_account'
}, undefined, {
revalidateOnFocus: false,
revalidateIfStale: false,
})
const account = data?.message?.default_cash_account
if (account) {
return <button className={cn('text-left border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
selectedAccount === account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
)}
type='button'
onClick={() => setSelectedAccount(account ?? '')}
>
<div className='flex items-center justify-center h-10 w-10'>
<Banknote size='24px' />
</div>
<div className='flex flex-col gap-1'>
<span className='font-semibold text-sm'>Cash</span>
<span className='text-xs text-ink-gray-5'>{data?.message?.default_cash_account}</span>
</div>
</button>
}
return null
}
const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => {
const { setValue, watch } = useFormContext<InternalTransferFormFields>()
const mirrorTransactionName = watch('mirror_transaction_name')
const paid_from = watch('paid_from')
const paid_to = watch('paid_to')
const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', {
transaction_id: transaction.name
}, undefined, {
revalidateOnFocus: false,
revalidateIfStale: false,
})
// Get bank accounts to find the logo
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
if (data?.message?.bank_account && banks) {
return banks.find(bank => bank.name === data.message.bank_account)
}
return null
}, [data?.message?.bank_account, banks])
const selectTransaction = () => {
if (data?.message) {
setValue('mirror_transaction_name', data.message.name)
onAccountChange(data.message.account, true)
}
}
if (data?.message) {
const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0
const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit
const currency = data.message.currency
const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account
const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected
return (<div className='pb-2'>
<div className={cn("flex justify-between items-start gap-3 p-3 border rounded-lg shadow-sm",
isSuggested ? "border-outline-green-4 bg-surface-green-1" : "border-outline-violet-2 bg-surface-violet-2/50")}>
<div>
<div className='flex flex-col gap-3'>
<div className={cn("flex items-center gap-2 shrink-0",
isSuggested ? "text-ink-green-4" : "text-ink-violet-4"
)}>
<BadgeCheck className="w-4 h-4" />
<span className="text-sm font-medium">{_("Suggested Transfer to {0}", [data.message.account])}</span>
</div>
<div className='flex flex-col gap-1'>
<span className='text-p-sm'>{_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])}</span>
<span className='text-p-sm'>{_("Accepting the suggestion will reconcile both transactions.")}</span>
</div>
<div className='flex flex-col gap-1.5'>
<div className='flex items-center gap-1'>
<Calendar size='16px' />
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
</div>
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
</div>
</div>
</div>
<div className='flex flex-col items-end justify-between gap-2 h-full w-[30%]'>
<div className="flex items-center gap-2">
<BankLogo bank={bank} iconSize='24px' imageClassName='h-8 max-w-24' iconClassName={cn(isSuggested ? "text-ink-green-3" : "text-purple-600")} />
</div>
<div className='flex gap-1'>
<div className={cn('flex items-center gap-1 text-end px-0 justify-end py-1 rounded-sm',
isWithdrawal ? 'text-ink-red-3' : 'text-ink-green-3'
)}>
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
<span className='text-sm font-semibold uppercase'>{isWithdrawal ? _('Transferred Out') : _('Received')}</span>
</div>
</div>
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
<div className='pt-1'>
<Button
onClick={selectTransaction}
theme={isSuggested ? "green" : "violet"}
size="md"
type='button'
>
{isSuggested ? <CheckCircle /> : <CheckIcon />}
{isSuggested ? _("Accepted") : _("Use Suggestion")}
</Button>
</div>
</div>
</div>
</div>
)
}
return null
}
export default TransferModalContent

View File

@@ -1,6 +1,5 @@
import CSVRawDataPreview from './CSVRawDataPreview'
import StatementDetails from './StatementDetails'
import _ from '@/lib/translate'
import { GetStatementDetailsResponse } from '../import_utils'
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {

View File

@@ -4,7 +4,7 @@ import { KeyboardMetaKeyIcon } from '@/components/ui/keyboard-keys'
import { SettingsPanelDescription, SettingsPanelTitle, SettingsPanelHeader, SettingsPanelContent } from '@/components/ui/settings-dialog'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import _ from '@/lib/translate'
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, OptionIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
const Shortcuts = [
{
@@ -32,7 +32,7 @@ const Shortcuts = [
}
},
{
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
shortcut: <KbdGroup><Kbd><OptionIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
action: {
icon: <ZapIcon />,
label: _("Accept Matching Rule"),

View File

@@ -20,7 +20,7 @@ export const Preferences = () => {
const { updateDoc, error } = useFrappeUpdateDoc<AccountsSettings>()
const onUpdate = (field: keyof AccountsSettings, value: any) => {
const onUpdate = <K extends keyof AccountsSettings>(field: K, value: AccountsSettings[K]) => {
mutate(updateDoc("Accounts Settings", "Accounts Settings", {
[field]: value
}), {

View File

@@ -1,95 +1,42 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
import {
SettingsDialog,
SettingsPanel,
SettingsPanels,
SettingsTabGroup,
SettingsTabItem,
SettingsTabs,
} from '@/components/ui/settings-dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import _ from '@/lib/translate'
import { KeyboardIcon, SettingsIcon, SlidersVerticalIcon, ZapIcon } from 'lucide-react'
import { SettingsIcon } from 'lucide-react'
import { useState } from 'react'
import { Preferences } from './Preferences'
import MatchingRules from './MatchingRules'
import KeyboardShortcuts from './KeyboardShortcuts'
import { useHotkeys } from 'react-hotkeys-hook'
import SettingsDialogContent from './SettingsDialogContent'
const Settings = () => {
const [isOpen, setIsOpen] = useState(false)
const [isOpen, setIsOpen] = useState(false)
useHotkeys('shift+meta+g', () => {
setIsOpen(x => !x)
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: false
})
useHotkeys('shift+meta+g', () => {
setIsOpen(x => !x)
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: false
})
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant={'outline'} isIconButton size='md'>
<SettingsIcon />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Settings")}
</TooltipContent>
</Tooltip>
<SettingsDialog defaultValue="preferences" onClose={() => setIsOpen(false)}>
<SettingsTabs>
<SettingsTabGroup header={_("Settings")}>
<SettingsTabItem
icon={<SlidersVerticalIcon />}
label={_("Preferences")}
value="preferences"
/>
<SettingsTabItem
icon={<ZapIcon />}
label={_("Matching Rules")}
value="rules"
/>
{/* <SettingsTabItem
icon={<LandmarkIcon />}
label={_("Bank Accounts")}
value="bank-accounts"
/>
<SettingsTabItem
icon={<ListIcon />}
label={_("Masters")}
value="masters"
/> */}
<SettingsTabItem
icon={<KeyboardIcon />}
label={_("Keyboard Shortcuts")}
value="keyboard-shortcuts"
/>
</SettingsTabGroup>
</SettingsTabs>
<SettingsPanels>
<SettingsPanel value="preferences">
<Preferences />
</SettingsPanel>
<SettingsPanel value="rules">
<MatchingRules />
</SettingsPanel>
<SettingsPanel value="bank-accounts" />
<SettingsPanel value="masters" />
<SettingsPanel value="keyboard-shortcuts">
<KeyboardShortcuts />
</SettingsPanel>
</SettingsPanels>
</SettingsDialog>
</Dialog >
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant={'outline'} isIconButton size='md' aria-label={_("Settings")}>
<SettingsIcon />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Settings")}
</TooltipContent>
</Tooltip>
{isOpen && (
<SettingsDialogContent onClose={() => setIsOpen(false)} />
)}
</Dialog>
)
}
export default Settings

View File

@@ -0,0 +1,52 @@
import {
SettingsDialog,
SettingsPanels,
SettingsTabGroup,
SettingsTabItem,
SettingsTabs,
} from '@/components/ui/settings-dialog'
import _ from '@/lib/translate'
import { KeyboardIcon, Loader2Icon, SlidersVerticalIcon, ZapIcon } from 'lucide-react'
import { lazy, Suspense } from 'react'
const SettingsPanelsContent = lazy(() => import('./SettingsPanelsContent'))
const SettingsPanelsFallback = () => (
<div className="flex flex-1 items-center justify-center min-h-full">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
)
const SettingsDialogContent = ({ onClose }: { onClose: () => void }) => {
return (
<SettingsDialog defaultValue="preferences" onClose={onClose}>
<SettingsTabs>
<SettingsTabGroup header={_("Settings")}>
<SettingsTabItem
icon={<SlidersVerticalIcon />}
label={_("Preferences")}
value="preferences"
/>
<SettingsTabItem
icon={<ZapIcon />}
label={_("Matching Rules")}
value="rules"
/>
<SettingsTabItem
icon={<KeyboardIcon />}
label={_("Keyboard Shortcuts")}
value="keyboard-shortcuts"
/>
</SettingsTabGroup>
</SettingsTabs>
<SettingsPanels>
<Suspense fallback={<SettingsPanelsFallback />}>
<SettingsPanelsContent />
</Suspense>
</SettingsPanels>
</SettingsDialog>
)
}
export default SettingsDialogContent

View File

@@ -0,0 +1,24 @@
import { SettingsPanel } from '@/components/ui/settings-dialog'
import { Preferences } from './Preferences'
import MatchingRules from './MatchingRules'
import KeyboardShortcuts from './KeyboardShortcuts'
const SettingsPanelsContent = () => {
return (
<>
<SettingsPanel value="preferences">
<Preferences />
</SettingsPanel>
<SettingsPanel value="rules">
<MatchingRules />
</SettingsPanel>
<SettingsPanel value="bank-accounts" />
<SettingsPanel value="masters" />
<SettingsPanel value="keyboard-shortcuts">
<KeyboardShortcuts />
</SettingsPanel>
</>
)
}
export default SettingsPanelsContent

View File

@@ -170,7 +170,7 @@ function AlertDialogCancel({
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size" | "theme">) {
return (
<Button variant={variant} size={size} asChild>
<Button variant={variant} size={size} theme={theme} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}

View File

@@ -18,22 +18,10 @@ interface ParsedErrorMessage {
}
const parseHeading = (message?: ParsedErrorMessage) => {
if (message?.title === 'Message' || message?.title === 'Error') return "There was an error."
if (message?.title === 'Message' || message?.title === 'Error') return _("There was an error.")
return message?.title
}
const wrapLooseListItemsWithUl = (html: string): string => {
// Regex matches consecutive <li>...</li> blocks not wrapped in <ul> or <ol>
// It wraps them in a <ul> if not already wrapped.
return html.replace(/(?:^|[^>])((<li[\s\S]*?<\/li>)+)(?![\s\S]*?<\/ul>)(?![\s\S]*?<\/ol>)/g, (match, p1) => {
// Check if the match already has <ul> or <ol> wrapping (simple check)
if (/^<ul>/.test(p1) || /^<ol>/.test(p1)) {
return match // Already wrapped, keep as is
}
return match.replace(p1, `<ul>${p1}</ul>`)
})
}
const ErrorBanner = ({ error, overrideHeading, ...props }: ErrorBannerProps) => {
@@ -53,8 +41,7 @@ const ErrorBanner = ({ error, overrideHeading, ...props }: ErrorBannerProps) =>
<AlertTitle>{overrideHeading ?? parseHeading(messages[0])}</AlertTitle>
<AlertDescription>
{messages.map((m, i) => {
const safeMessage = wrapLooseListItemsWithUl(m.message)
return <MarkdownRenderer content={safeMessage} key={i} />
return <MarkdownRenderer content={m.message} key={i} />
})}
</AlertDescription>
</Alert>

View File

@@ -0,0 +1,7 @@
import { Loader2Icon } from 'lucide-react'
export const ModalContentFallback = () => (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
)

View File

@@ -151,7 +151,7 @@ function SettingsTabItem({
)}
<span
className={cn(
"flex-1 shrink-0 truncate text-sm duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
"flex-1 shrink-0 truncate text-sm leading-4 duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
icon && "ms-2"
)}
>

View File

@@ -0,0 +1,37 @@
import { useCallback, useRef, useState } from "react"
/** Tracks per-file upload progress (01) and exposes their average. */
export function useMultiFileUploadProgress() {
const [uploadProgress, setUploadProgress] = useState(0)
const fileProgressesRef = useRef<number[]>([])
const startTracking = useCallback((fileCount: number) => {
if (fileCount <= 0) {
return
}
fileProgressesRef.current = new Array(fileCount).fill(0)
setUploadProgress(0)
}, [])
const updateFileProgress = useCallback((fileIndex: number, progress: number) => {
if (fileIndex < 0 || fileIndex >= fileProgressesRef.current.length) {
return
}
if (fileProgressesRef.current.length === 0) {
return
}
fileProgressesRef.current[fileIndex] = progress
const total =
fileProgressesRef.current.reduce((sum, p) => sum + p, 0) /
fileProgressesRef.current.length
setUploadProgress(total)
}, [])
const resetProgress = useCallback(() => {
fileProgressesRef.current = []
setUploadProgress(0)
}, [])
return { uploadProgress, startTracking, updateFileProgress, resetProgress }
}

View File

@@ -1,4 +1,3 @@
import { in_list } from "./checks";
import { getCurrencyNumberFormat, getCurrencyProperty, getCurrencySymbol } from "./currency";
import { getSystemDefault } from "./frappe";
import _ from "@/lib/translate";

View File

@@ -1,20 +1,16 @@
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
import BankClearanceSummary from "@/components/features/BankReconciliation/BankClearanceSummary"
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
import BankRecDateFilter from "@/components/features/BankReconciliation/BankRecDateFilter"
import BankReconciliationStatement from "@/components/features/BankReconciliation/BankReconciliationStatement"
import BankTransactions from "@/components/features/BankReconciliation/BankTransactionList"
import BankTransactionUnreconcileModal from "@/components/features/BankReconciliation/BankTransactionUnreconcileModal"
import CompanySelector from "@/components/features/BankReconciliation/CompanySelector"
import IncorrectlyClearedEntries from "@/components/features/BankReconciliation/IncorrectlyClearedEntries"
import MatchAndReconcile from "@/components/features/BankReconciliation/MatchAndReconcile"
import Settings from "@/components/features/Settings/Settings"
import ActionLog from "@/components/features/ActionLog/ActionLog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { TooltipProvider } from "@/components/ui/tooltip"
import _ from "@/lib/translate"
import { useLayoutEffect, useRef, useState } from "react"
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react"
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, Loader2Icon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Badge } from "@/components/ui/badge"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
@@ -22,6 +18,10 @@ import { Button } from "@/components/ui/button"
import { useAtomValue } from "jotai"
import { selectedBankAccountAtom } from "@/components/features/BankReconciliation/bankRecAtoms"
const BankReconciliationStatement = lazy(() => import('@/components/features/BankReconciliation/BankReconciliationStatement'))
const BankTransactions = lazy(() => import('@/components/features/BankReconciliation/BankTransactionList'))
const BankClearanceSummary = lazy(() => import('@/components/features/BankReconciliation/BankClearanceSummary'))
const IncorrectlyClearedEntries = lazy(() => import('@/components/features/BankReconciliation/IncorrectlyClearedEntries'))
const BankReconciliation = () => {
@@ -35,7 +35,7 @@ const BankReconciliation = () => {
}
}, [])
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 270
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 220
return (
<div>
@@ -122,18 +122,24 @@ const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: n
<TabsContent value="Match and Reconcile">
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} />
</TabsContent>
<TabsContent value="Bank Reconciliation Statement">
<BankReconciliationStatement />
</TabsContent>
<TabsContent value="Bank Transactions">
<BankTransactions />
</TabsContent>
<TabsContent value="Bank Clearance Summary">
<BankClearanceSummary />
</TabsContent>
<TabsContent value="Incorrectly Cleared Entries">
<IncorrectlyClearedEntries />
</TabsContent>
<Suspense fallback={
<div className="flex items-center justify-center p-16">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
}>
<TabsContent value="Bank Reconciliation Statement">
<BankReconciliationStatement />
</TabsContent>
<TabsContent value="Bank Transactions">
<BankTransactions />
</TabsContent>
<TabsContent value="Bank Clearance Summary">
<BankClearanceSummary />
</TabsContent>
<TabsContent value="Incorrectly Cleared Entries">
<IncorrectlyClearedEntries />
</TabsContent>
</Suspense>
</Tabs>
}

View File

@@ -1,6 +1,7 @@
import { Suspense } from 'react'
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbList } from '@/components/ui/breadcrumb'
import _ from '@/lib/translate'
import { HomeIcon } from 'lucide-react'
import { HomeIcon, Loader2Icon } from 'lucide-react'
import { Link, Outlet } from 'react-router'
const BankStatementImporterContainer = () => {
@@ -29,7 +30,13 @@ const BankStatementImporterContainer = () => {
</BreadcrumbList>
</Breadcrumb>
</div>
<Outlet />
<Suspense fallback={
<div className="flex flex-1 items-center justify-center p-16">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
}>
<Outlet />
</Suspense>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import CSVImport from '@/components/features/BankStatementImporter/CSV/CSVImport'
import { lazy } from 'react'
import { useGetStatementDetails } from '@/components/features/BankStatementImporter/import_utils'
import { Button } from '@/components/ui/button'
import { useDirection } from '@/components/ui/direction'
@@ -8,6 +8,8 @@ import { useFrappeDocumentEventListener } from 'frappe-react-sdk'
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import { Link, useParams } from 'react-router'
const CSVImport = lazy(() => import('@/components/features/BankStatementImporter/CSV/CSVImport'))
const ViewBankStatementImportLog = () => {
const { id } = useParams<{ id: string }>()

View File

@@ -1,6 +1,6 @@
import { BankStatementImportLogColumnMap } from './BankStatementImportLogColumnMap'
export interface BankStatementImportLog{
export interface BankStatementImportLog {
name: string
creation: string
modified: string
@@ -38,7 +38,7 @@ export interface BankStatementImportLog{
/** Detected Date Format : Data */
detected_date_format?: string
/** Detected Amount Format : Select */
detected_amount_format?: "Separate columns for withdrawal and deposit" | "Amount column has "CR"/"DR" values" | "Amount column has positive/negative values" | "Transaction type column has "CR"/"DR" values" | "Transaction type column has "Deposit"/"Withdrawal" values" | "Transaction type column has "C"/"D" values"
detected_amount_format?: "Separate columns for withdrawal and deposit" | "Amount column has \"CR\"/\"DR\" values" | "Amount column has positive/negative values" | "Transaction type column has \"CR\"/\"DR\" values" | "Transaction type column has \"Deposit\"/\"Withdrawal\" values" | "Transaction type column has \"C\"/\"D\" values"
/** Detected Header Index : Int */
detected_header_index?: number
/** Detected Transaction Starting Index : Int */

View File

@@ -21,5 +21,35 @@ export default defineConfig({
outDir: '../erpnext/public/banking',
emptyOutDir: true,
target: 'es2015',
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) {
return
}
if (id.includes('react-dom') || id.includes('/react/')) {
return 'vendor-react'
}
if (id.includes('frappe-react-sdk')) {
return 'vendor-frappe'
}
if (id.includes('@tanstack')) {
return 'vendor-tanstack'
}
if (id.includes('fuse.js')) {
return 'vendor-fuse'
}
if (id.includes('radix-ui') || id.includes('@radix-ui')) {
return 'vendor-radix'
}
if (id.includes('jotai')) {
return 'vendor-jotai'
}
if (id.includes('lucide-react')) {
return 'vendor-lucide'
}
},
},
},
},
});

View File

@@ -3333,11 +3333,6 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
get-nonce "^1.0.0"
tslib "^2.0.0"
react-virtuoso@^4.18.6:
version "4.18.6"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.18.6.tgz#953637adf805d562892270aafdeeedb0bda1881b"
integrity sha512-CrT3P6HyjJMHZVWSste2bG2q5aWGlHfW2QuySZjiFwB2Qok/xsvgy+k8Z2jeDP8PP5KsBip7zNrl/F0QoxeyKw==
react@^19.2.6:
version "19.2.6"
resolved "https://registry.yarnpkg.com/react/-/react-19.2.6.tgz#3dadb8e12b2a7934c1d5317973e5dce1301f9a4d"

View File

@@ -1,126 +0,0 @@
{
"custom_fields": [
{
"_assign": null,
"_comments": null,
"_liked_by": null,
"_user_tags": null,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"creation": "2018-12-28 22:29:21.828090",
"default": null,
"depends_on": null,
"description": null,
"docstatus": 0,
"dt": "Address",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "tax_category",
"fieldtype": "Link",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"idx": 15,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "fax",
"label": "Tax Category",
"length": 0,
"mandatory_depends_on": null,
"modified": "2018-12-28 22:29:21.828090",
"modified_by": "Administrator",
"name": "Address-tax_category",
"no_copy": 0,
"options": "Tax Category",
"owner": "Administrator",
"parent": null,
"parentfield": null,
"parenttype": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"translatable": 0,
"unique": 0,
"width": null
},
{
"_assign": null,
"_comments": null,
"_liked_by": null,
"_user_tags": null,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"creation": "2020-10-14 17:41:40.878179",
"default": "0",
"depends_on": null,
"description": null,
"docstatus": 0,
"dt": "Address",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "is_your_company_address",
"fieldtype": "Check",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"idx": 20,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "linked_with",
"label": "Is Your Company Address",
"length": 0,
"mandatory_depends_on": null,
"modified": "2020-10-14 17:41:40.878179",
"modified_by": "Administrator",
"name": "Address-is_your_company_address",
"no_copy": 0,
"options": null,
"owner": "Administrator",
"parent": null,
"parentfield": null,
"parenttype": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"translatable": 0,
"unique": 0,
"width": null
}
],
"custom_perms": [],
"doctype": "Address",
"property_setters": [],
"sync_on_migrate": 1
}

View File

@@ -126,7 +126,7 @@
"label": "Account Type",
"oldfieldname": "account_type",
"oldfieldtype": "Select",
"options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nRound Off for Opening\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary",
"options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nRound Off for Opening\nStock\nStock Adjustment\nStock Received But Not Billed\nStock Delivered But Not Billed\nService Received But Not Billed\nTax\nTemporary",
"search_index": 1
},
{

View File

@@ -65,6 +65,7 @@ class Account(NestedSet):
"Stock",
"Stock Adjustment",
"Stock Received But Not Billed",
"Stock Delivered But Not Billed",
"Service Received But Not Billed",
"Tax",
"Temporary",
@@ -174,16 +175,19 @@ class Account(NestedSet):
if cint(self.is_group):
db_value = self.get_doc_before_save()
if db_value:
Account = frappe.qb.DocType("Account")
query = frappe.qb.update(Account).where((Account.lft > self.lft) & (Account.rgt < self.rgt))
updated = False
if self.report_type != db_value.report_type:
frappe.db.sql(
"update `tabAccount` set report_type=%s where lft > %s and rgt < %s",
(self.report_type, self.lft, self.rgt),
)
query = query.set(Account.report_type, self.report_type)
updated = True
if self.root_type != db_value.root_type:
frappe.db.sql(
"update `tabAccount` set root_type=%s where lft > %s and rgt < %s",
(self.root_type, self.lft, self.rgt),
)
query = query.set(Account.root_type, self.root_type)
updated = True
if updated:
query.run()
if self.root_type and not self.report_type:
self.report_type = (
@@ -448,11 +452,7 @@ class Account(NestedSet):
return frappe.db.get_value("GL Entry", {"account": self.name})
def check_if_child_exists(self):
return frappe.db.sql(
"""select name from `tabAccount` where parent_account = %s
and docstatus != 2""",
self.name,
)
return frappe.db.exists("Account", {"parent_account": self.name, "docstatus": ["!=", 2]})
def validate_mandatory(self):
if not self.root_type:
@@ -472,14 +472,24 @@ class Account(NestedSet):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_parent_account(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
return frappe.db.sql(
"""select name from tabAccount
where is_group = 1 and docstatus != 2 and company = {}
and {} like {} order by name limit {} offset {}""".format("%s", searchfield, "%s", "%s", "%s"),
(filters["company"], "%%%s%%" % txt, page_len, start),
as_list=1,
Account = frappe.qb.DocType("Account")
search_field_obj = getattr(Account, searchfield)
query = (
frappe.qb.from_(Account)
.select(Account.name)
.where(Account.is_group == 1)
.where(Account.docstatus != 2)
.where(Account.company == filters["company"])
.where(search_field_obj.like(f"%{txt}%"))
.order_by(Account.name)
.limit(page_len)
.offset(start)
)
return query.run(as_list=1)
def get_account_currency(account):
"""Helper function to get account currency"""
@@ -520,6 +530,7 @@ def update_account_number(
):
_ensure_idle_system()
account = frappe.get_cached_doc("Account", name)
account.check_permission("write")
if not account:
return
@@ -673,6 +684,7 @@ def get_company_default_account_fields():
"default_expense_account": "Default Expense Account",
"default_income_account": "Default Income Account",
"stock_received_but_not_billed": "Stock Received But Not Billed Account",
"stock_delivered_but_not_billed": "Stock Delivered But Not Billed Account",
"stock_adjustment_account": "Stock Adjustment Account",
"write_off_account": "Write Off Account",
"default_discount_account": "Default Payment Discount Account",

View File

@@ -378,6 +378,9 @@
"Passifs de stock": {
"Stock re\u00e7u non factur\u00e9": {
"account_type": "Stock Received But Not Billed"
},
"Stock livr\u00e9 non factur\u00e9": {
"account_type": "Stock Delivered But Not Billed"
}
},
"Provision pour vacances et cong\u00e9s": {},

View File

@@ -221,6 +221,10 @@
"account_number": "1702",
"account_type": "Stock Received But Not Billed"
},
"Warenausgangs-Verrechnungskonto": {
"account_number": "1703",
"account_type": "Stock Delivered But Not Billed"
},
"Verbindlichkeiten aus Lohn und Gehalt": {
"account_number": "1740",
"account_type": "Payable"

View File

@@ -1144,6 +1144,10 @@
"Wareneingangs-­Verrechnungskonto" : {
"account_number": "70001",
"account_type": "Stock Received But Not Billed"
},
"Warenausgangs-Verrechnungskonto" : {
"account_number": "70002",
"account_type": "Stock Delivered But Not Billed"
}
},
"Verb. aus Lieferungen und Leistungen": {

View File

@@ -1076,6 +1076,9 @@
"account_type": "Stock Received But Not Billed",
"account_number": "4088"
}
},
"Stock livr\u00e9 non factur\u00e9": {
"account_type": "Stock Delivered But Not Billed"
}
},
"41-Clients et comptes rattach\u00e9s (PASSIF)": {

View File

@@ -1589,6 +1589,9 @@
"account_type": "Stock Received But Not Billed",
"account_number": "4088"
}
},
"Stock livr\u00e9 non factur\u00e9": {
"account_type": "Stock Delivered But Not Billed"
}
},
"41-Clients et comptes rattach\u00e9s (PASSIF)": {

View File

@@ -1592,6 +1592,9 @@
"account_number": "4088"
},
"account_number": "408"
},
"Stock livr\u00e9 non factur\u00e9": {
"account_type": "Stock Delivered But Not Billed"
}
},
"41-Clients et comptes rattach\u00e9s (PASSIF)": {

View File

@@ -805,6 +805,9 @@
},
"account_type": "Stock Received But Not Billed"
},
"Stock livr\u00e9 non factur\u00e9": {
"account_type": "Stock Delivered But Not Billed"
},
"account_type": "Payable"
},
"41-Clients et comptes rattach\u00e9s (PASSIF)": {

View File

@@ -1520,6 +1520,9 @@
"account_number": "4088"
},
"account_number": "408"
},
"Stock livr\u00e9 non factur\u00e9": {
"account_type": "Stock Delivered But Not Billed"
}
},
"41-Clients et comptes rattach\u00e9s (PASSIF)": {

View File

@@ -223,6 +223,10 @@
"Stock Received But Not Billed": {
"account_type": "Stock Received But Not Billed",
"account_category": "Trade Payables"
},
"Stock Delivered But Not Billed": {
"account_type": "Stock Delivered But Not Billed",
"account_category": "Trade Payables"
}
},
"Duties and Taxes": {

View File

@@ -0,0 +1,840 @@
{
"name": "Philippines",
"country": "Philippines",
"tree": {
"Asset": {
"account_number": "1000",
"is_group": 1,
"root_type": "Asset",
"Current Assets": {
"account_number": "1001",
"is_group": 1,
"root_type": "Asset",
"Cash": {
"account_number": "1100",
"is_group": 1,
"root_type": "Asset",
"account_type": "Cash",
"Cash on Hand": {
"account_number": "1101",
"is_group": 0,
"root_type": "Asset",
"account_type": "Cash"
},
"Petty Cash Fund": {
"account_number": "1200",
"is_group": 1,
"root_type": "Asset",
"account_type": "Cash",
"Petty Cash Fund": {
"account_number": "1201",
"is_group": 0,
"root_type": "Asset",
"account_type": "Cash"
}
}
},
"Bank Accounts": {
"account_number": "1102",
"is_group": 1,
"root_type": "Asset",
"account_type": "Bank"
},
"Advances to Officers & Employees": {
"account_number": "1290",
"is_group": 1,
"root_type": "Asset",
"Advances to Officers & Employees": {
"account_number": "1291",
"is_group": 0,
"root_type": "Asset"
}
},
"Accounts Receivable Trade": {
"account_number": "1300",
"is_group": 1,
"root_type": "Asset",
"Accounts Receivable - Trade": {
"account_number": "1301",
"is_group": 0,
"root_type": "Asset",
"account_type": "Receivable"
}
},
"Accounts Receivable - Affiliates": {
"account_number": "1310",
"is_group": 1,
"root_type": "Asset",
"Due from Company": {
"account_number": "1311",
"is_group": 0,
"root_type": "Asset"
}
},
"Accounts Receivable - Others": {
"account_number": "1400",
"is_group": 1,
"root_type": "Asset",
"Accounts Receivable - Others": {
"account_number": "1401",
"is_group": 0,
"root_type": "Asset"
}
},
"Parts, Materials and Supplies": {
"account_number": "1500",
"is_group": 1,
"root_type": "Asset",
"Parts, Materials and Supplies": {
"account_number": "1501",
"is_group": 0,
"root_type": "Asset"
},
"Raw Materials - Demo": {
"account_number": "1502",
"is_group": 0,
"root_type": "Asset"
}
},
"Project in Progress": {
"account_number": "1510",
"is_group": 1,
"root_type": "Asset",
"Project in Progress": {
"account_number": "1511",
"is_group": 0,
"root_type": "Asset"
},
"Factory Overhead Variance": {
"account_number": "1512",
"is_group": 0,
"root_type": "Asset"
}
},
"Finished Goods": {
"account_number": "1520",
"is_group": 1,
"root_type": "Asset",
"Finished Goods Inventory": {
"account_number": "1531",
"is_group": 0,
"root_type": "Asset",
"account_type": "Stock"
},
"Inventory in Transit": {
"account_number": "1532",
"is_group": 0,
"root_type": "Asset",
"account_type": "Stock Adjustment"
}
},
"Prepayments": {
"account_number": "1600",
"is_group": 1,
"root_type": "Asset",
"Prepaid Insurance & Bonds": {
"account_number": "1601",
"is_group": 0,
"root_type": "Asset"
},
"Prepaid Rent": {
"account_number": "1602",
"is_group": 0,
"root_type": "Asset"
}
},
"VAT Input Tax": {
"account_number": "1610",
"is_group": 1,
"root_type": "Asset",
"VAT Input Tax - Goods": {
"account_number": "1611",
"is_group": 0,
"root_type": "Asset",
"account_type": "Tax"
}
}
},
"Non - Current Assets": {
"account_number": "1002",
"is_group": 1,
"root_type": "Asset",
"Property, Plants And Equipments": {
"account_number": "1700",
"is_group": 1,
"root_type": "Asset",
"Land": {
"account_number": "1701",
"is_group": 0,
"root_type": "Asset",
"account_type": "Fixed Asset"
},
"Buildings & Improvements": {
"account_number": "1702",
"is_group": 0,
"root_type": "Asset",
"account_type": "Fixed Asset"
},
"Delivery & Trans Equipment": {
"account_number": "1703",
"is_group": 0,
"root_type": "Asset",
"account_type": "Fixed Asset"
},
"Furniture & Fixtures": {
"account_number": "1704",
"is_group": 0,
"root_type": "Asset",
"account_type": "Fixed Asset"
},
"Machinery & Equipment": {
"account_number": "1705",
"is_group": 0,
"root_type": "Asset",
"account_type": "Fixed Asset"
}
},
"Accum Depr. - Property, Plants and Equipment": {
"account_number": "1800",
"is_group": 1,
"root_type": "Asset",
"Accumulated Dep Bdgs & Improv": {
"account_number": "1801",
"is_group": 0,
"root_type": "Asset",
"account_type": "Accumulated Depreciation"
},
"Accumulated Dep Delivery & Trans": {
"account_number": "1802",
"is_group": 0,
"root_type": "Asset",
"account_type": "Accumulated Depreciation"
},
"Accumulated Dep Furniture & Fixture": {
"account_number": "1803",
"is_group": 0,
"root_type": "Asset",
"account_type": "Accumulated Depreciation"
},
"Accumulated Depreciation - Machinery & Equipment": {
"account_number": "1804",
"is_group": 0,
"root_type": "Asset",
"account_type": "Accumulated Depreciation"
}
}
},
"Other Assets": {
"account_number": "1003",
"is_group": 1,
"root_type": "Asset",
"Advances To Supplier": {
"account_number": "1900",
"is_group": 1,
"root_type": "Asset",
"Advances To Supplier": {
"account_number": "1901",
"is_group": 0,
"root_type": "Asset"
}
},
"Miscellaneous Deposits": {
"account_number": "1910",
"is_group": 1,
"root_type": "Asset",
"Miscellaneous Deposits": {
"account_number": "1911",
"is_group": 0,
"root_type": "Asset"
}
},
"Retirement Fund": {
"account_number": "1920",
"is_group": 1,
"root_type": "Asset",
"Retirement Fund": {
"account_number": "1921",
"is_group": 0,
"root_type": "Asset"
}
},
"Investment": {
"account_number": "1930",
"is_group": 1,
"root_type": "Asset",
"Investment": {
"account_number": "1931",
"is_group": 0,
"root_type": "Asset"
}
},
"System Development": {
"account_number": "1940",
"is_group": 1,
"root_type": "Asset",
"System Development": {
"account_number": "1941",
"is_group": 0,
"root_type": "Asset"
}
}
}
},
"Liability": {
"account_number": "2000",
"is_group": 1,
"root_type": "Liability",
"Current Liabilities": {
"account_number": "2001",
"is_group": 1,
"root_type": "Liability",
"Accounts Payable Trade": {
"account_number": "2100",
"is_group": 1,
"root_type": "Liability",
"Accounts Payable - Trade": {
"account_number": "2101",
"is_group": 0,
"root_type": "Liability",
"account_type": "Payable"
}
},
"Accounts Payable Others": {
"account_number": "2110",
"is_group": 1,
"root_type": "Liability",
"Accounts Payable - Payroll": {
"account_number": "2111",
"is_group": 0,
"root_type": "Liability"
}
},
"VAT Output Tax": {
"account_number": "2200",
"is_group": 1,
"root_type": "Liability",
"VAT Output Tax": {
"account_number": "2201",
"is_group": 0,
"root_type": "Liability",
"account_type": "Tax"
}
},
"Withholding Taxes Payable Wages": {
"account_number": "2210",
"is_group": 1,
"root_type": "Liability",
"Withholding Taxes Payable Wages": {
"account_number": "2211",
"is_group": 0,
"root_type": "Liability",
"account_type": "Tax"
}
},
"Withholding Taxes Payable Expanded": {
"account_number": "2220",
"is_group": 1,
"root_type": "Liability",
"Withholding Taxes Payable Expanded": {
"account_number": "2221",
"is_group": 0,
"root_type": "Liability",
"account_type": "Tax"
}
},
"Accruals And Other Current Payables": {
"account_number": "2300",
"is_group": 1,
"root_type": "Liability",
"Stock Received But Not Billed": {
"account_number": "2301",
"is_group": 0,
"root_type": "Liability",
"account_type": "Stock Received But Not Billed"
}
},
"Payable to Government and Other Institutions": {
"account_number": "2400",
"is_group": 1,
"root_type": "Liability",
"SSS Premium Payable": {
"account_number": "2401",
"is_group": 0,
"root_type": "Liability"
},
"SSS Salary Loan Payable": {
"account_number": "2402",
"is_group": 0,
"root_type": "Liability"
},
"PhilHealth Premium": {
"account_number": "2403",
"is_group": 0,
"root_type": "Liability"
},
"Pag-ibig Loan Payable": {
"account_number": "2404",
"is_group": 0,
"root_type": "Liability"
},
"Coop Loans": {
"account_number": "2405",
"is_group": 0,
"root_type": "Liability"
},
"Coop Contributions": {
"account_number": "2406",
"is_group": 0,
"root_type": "Liability"
},
"Canteen": {
"account_number": "2407",
"is_group": 0,
"root_type": "Liability"
},
"AUB Loan Payable": {
"account_number": "2408",
"is_group": 0,
"root_type": "Liability"
},
"HSBC Loan Payable": {
"account_number": "2409",
"is_group": 0,
"root_type": "Liability"
}
},
"Customer Deposits": {
"account_number": "2500",
"is_group": 0,
"root_type": "Liability",
"account_type": "Payable"
}
},
"Non Current Liabilities": {
"account_number": "2002",
"is_group": 1,
"root_type": "Liability",
"Due To Associated Company": {
"account_number": "2600",
"is_group": 1,
"root_type": "Liability",
"Due To Associated Company": {
"account_number": "2601",
"is_group": 0,
"root_type": "Liability"
}
},
"Deferred Income": {
"account_number": "2700",
"is_group": 1,
"root_type": "Liability",
"Deferred Income": {
"account_number": "2701",
"is_group": 0,
"root_type": "Liability"
}
},
"Notes Payable": {
"account_number": "2800",
"is_group": 1,
"root_type": "Liability",
"Notes Payable": {
"account_number": "2801",
"is_group": 0,
"root_type": "Liability"
}
},
"Dividends Payable": {
"account_number": "2900",
"is_group": 0,
"root_type": "Liability"
}
}
},
"Equity": {
"account_number": "3000",
"is_group": 1,
"root_type": "Equity",
"STOCKHOLDER'S EQUITY": {
"account_number": "3001",
"is_group": 1,
"root_type": "Equity",
"Capital Stocks": {
"account_number": "3100",
"is_group": 1,
"root_type": "Equity",
"Capital Stocks": {
"account_number": "3101",
"is_group": 0,
"root_type": "Equity"
}
},
"Subscription Receivable": {
"account_number": "3200",
"is_group": 1,
"root_type": "Equity",
"Subscription Receivable": {
"account_number": "3201",
"is_group": 0,
"root_type": "Equity"
}
},
"Retained Earnings": {
"account_number": "3300",
"is_group": 1,
"root_type": "Equity",
"Retained Earnings": {
"account_number": "3301",
"is_group": 0,
"root_type": "Equity"
}
},
"Current Year (Profit/Loss)": {
"account_number": "3400",
"is_group": 0,
"root_type": "Equity"
},
"Drawings": {
"account_number": "3500",
"is_group": 1,
"root_type": "Equity",
"Drawings": {
"account_number": "3501",
"is_group": 0,
"root_type": "Equity"
}
}
}
},
"Income": {
"account_number": "4000",
"is_group": 1,
"root_type": "Income",
"Gross Sales": {
"account_number": "4100",
"is_group": 1,
"root_type": "Income",
"Sales": {
"account_number": "4101",
"is_group": 0,
"root_type": "Income"
}
},
"Sales Adjustment": {
"account_number": "4200",
"is_group": 1,
"root_type": "Income",
"Sales Return And Allowance": {
"account_number": "4201",
"is_group": 0,
"root_type": "Income"
}
},
"Sales Discount": {
"account_number": "4300",
"is_group": 1,
"root_type": "Income",
"Sales Discount": {
"account_number": "4301",
"is_group": 0,
"root_type": "Income"
}
},
"Other Income": {
"account_number": "6000",
"is_group": 1,
"root_type": "Income",
"Interest Income Bank": {
"account_number": "6010",
"is_group": 1,
"root_type": "Income",
"Interest Income Bank": {
"account_number": "6011",
"is_group": 0,
"root_type": "Income"
}
},
"Dividend Income": {
"account_number": "6020",
"is_group": 1,
"root_type": "Income",
"Dividend Income": {
"account_number": "6021",
"is_group": 0,
"root_type": "Income"
}
}
}
},
"Expense": {
"account_number": "5000",
"is_group": 1,
"root_type": "Expense",
"Operating Expenses": {
"account_number": "5100",
"is_group": 1,
"root_type": "Expense",
"Salaries, Wages": {
"account_number": "5101",
"is_group": 0,
"root_type": "Expense"
},
"13th Month Pay & Bonus": {
"account_number": "5102",
"is_group": 0,
"root_type": "Expense"
},
"Overtime & Night Diff": {
"account_number": "5103",
"is_group": 0,
"root_type": "Expense"
},
"Incentive/Performance Bonus": {
"account_number": "5104",
"is_group": 0,
"root_type": "Expense"
},
"Employees Benefits": {
"account_number": "5105",
"is_group": 0,
"root_type": "Expense"
},
"Advertising & Promotions": {
"account_number": "5106",
"is_group": 0,
"root_type": "Expense"
},
"Amortization of Leasehold Improvement": {
"account_number": "5107",
"is_group": 0,
"root_type": "Expense"
},
"Amortization of Pre-Operating": {
"account_number": "5108",
"is_group": 0,
"root_type": "Expense"
},
"Amortization of System Development": {
"account_number": "5109",
"is_group": 0,
"root_type": "Expense"
},
"Audit & Legal Fee": {
"account_number": "5110",
"is_group": 0,
"root_type": "Expense"
},
"Bad Debts Expenses": {
"account_number": "5111",
"is_group": 0,
"root_type": "Expense"
},
"Client Service & Maintenance": {
"account_number": "5112",
"is_group": 0,
"root_type": "Expense"
},
"Commission Expenses": {
"account_number": "5113",
"is_group": 0,
"root_type": "Expense"
},
"Communications": {
"account_number": "5114",
"is_group": 0,
"root_type": "Expense"
},
"Contractual Services": {
"account_number": "5115",
"is_group": 0,
"root_type": "Expense"
},
"Depreciation Expenses": {
"account_number": "5116",
"is_group": 0,
"root_type": "Expense",
"account_type": "Depreciation"
},
"Donation & Contribution": {
"account_number": "5117",
"is_group": 0,
"root_type": "Expense"
},
"Dues & Subscription": {
"account_number": "5118",
"is_group": 0,
"root_type": "Expense"
},
"Employee Med/Dental/Hosp Expenses": {
"account_number": "5119",
"is_group": 0,
"root_type": "Expense"
},
"Employee Uniforms": {
"account_number": "5120",
"is_group": 0,
"root_type": "Expense"
},
"Equipage": {
"account_number": "5121",
"is_group": 0,
"root_type": "Expense"
},
"Expenses for Reclassification": {
"account_number": "5122",
"is_group": 0,
"root_type": "Expense"
},
"Gas & Oil": {
"account_number": "5123",
"is_group": 0,
"root_type": "Expense"
},
"Insurance Expenses": {
"account_number": "5124",
"is_group": 0,
"root_type": "Expense"
},
"Light & Water": {
"account_number": "5125",
"is_group": 0,
"root_type": "Expense"
},
"Local/Overseas Travel": {
"account_number": "5126",
"is_group": 0,
"root_type": "Expense"
},
"Meals & Transportation Expenses": {
"account_number": "5127",
"is_group": 0,
"root_type": "Expense"
},
"Meeting & Conferences": {
"account_number": "5128",
"is_group": 0,
"root_type": "Expense"
},
"Miscellaneous Expenses": {
"account_number": "5129",
"is_group": 0,
"root_type": "Expense"
},
"Mockup Expenses": {
"account_number": "5130",
"is_group": 0,
"root_type": "Expense"
},
"Obsolescence Expenses": {
"account_number": "5131",
"is_group": 0,
"root_type": "Expense"
},
"Other Support Cost": {
"account_number": "5132",
"is_group": 0,
"root_type": "Expense"
},
"Pag-ibig Contribution": {
"account_number": "5133",
"is_group": 0,
"root_type": "Expense"
},
"Performance Bonds": {
"account_number": "5134",
"is_group": 0,
"root_type": "Expense"
},
"Pre Employment Expenses": {
"account_number": "5135",
"is_group": 0,
"root_type": "Expense"
},
"Professional Fees": {
"account_number": "5136",
"is_group": 0,
"root_type": "Expense"
},
"Recruitment & Employment": {
"account_number": "5137",
"is_group": 0,
"root_type": "Expense"
},
"Rent Expenses": {
"account_number": "5138",
"is_group": 0,
"root_type": "Expense"
},
"Rent Expenses Others": {
"account_number": "5139",
"is_group": 0,
"root_type": "Expense"
},
"Repairs & Maintenance": {
"account_number": "5140",
"is_group": 0,
"root_type": "Expense"
},
"Representation Expenses": {
"account_number": "5141",
"is_group": 0,
"root_type": "Expense"
},
"Research & Development": {
"account_number": "5142",
"is_group": 0,
"root_type": "Expense"
},
"Security Expenses": {
"account_number": "5143",
"is_group": 0,
"root_type": "Expense"
},
"Shared Services Fee": {
"account_number": "5144",
"is_group": 0,
"root_type": "Expense"
},
"SSS/Medicare/EC Contributions": {
"account_number": "5145",
"is_group": 0,
"root_type": "Expense"
},
"Stationery & Supplies": {
"account_number": "5146",
"is_group": 0,
"root_type": "Expense"
},
"Taxes & Licenses": {
"account_number": "5147",
"is_group": 0,
"root_type": "Expense",
"account_type": "Tax"
},
"Training & Seminar": {
"account_number": "5148",
"is_group": 0,
"root_type": "Expense"
}
},
"Stock Adjustment": {
"account_number": "5200",
"is_group": 0,
"root_type": "Expense",
"account_type": "Stock Adjustment"
},
"Round Off": {
"account_number": "5300",
"is_group": 0,
"root_type": "Expense",
"account_type": "Round Off"
},
"Expenses Included In Valuation": {
"account_number": "5400",
"is_group": 0,
"root_type": "Expense",
"account_type": "Expenses Included In Valuation"
}
}
}
}

View File

@@ -35,6 +35,10 @@ def get():
_("Short-term Investments"): {"account_category": "Short-term Investments"},
_("Stock Assets"): {
_("Stock In Hand"): {"account_type": "Stock", "account_category": "Stock Assets"},
_("Stock Delivered But Not Billed"): {
"account_type": "Stock Delivered But Not Billed",
"account_category": "Stock Assets",
},
"account_type": "Stock",
"account_category": "Stock Assets",
},

View File

@@ -62,6 +62,11 @@ def get():
"account_number": "1410",
"account_category": "Stock Assets",
},
_("Stock Delivered But Not Billed"): {
"account_type": "Stock Delivered But Not Billed",
"account_number": "1420",
"account_category": "Stock Assets",
},
"account_type": "Stock",
"account_number": "1400",
"account_category": "Stock Assets",

View File

@@ -43,6 +43,7 @@ class AccountingDimension(Document):
def validate(self):
self.validate_doctype()
validate_column_name(self.fieldname)
self.validate_fieldname_conflict()
self.validate_dimension_defaults()
def validate_doctype(self):
@@ -74,6 +75,27 @@ class AccountingDimension(Document):
message += _("Please create a new Accounting Dimension if required.")
frappe.throw(message)
def validate_fieldname_conflict(self):
conflicting_doctypes = []
for doctype in get_doctypes_with_dimensions():
meta = frappe.get_meta(doctype, cached=False)
if any(f.fieldname == self.fieldname for f in meta.get("fields")):
conflicting_doctypes.append(doctype)
if conflicting_doctypes:
frappe.msgprint(
_(
"Fieldname {0} already exists in the following doctypes: {1}. "
"A separate dimension field will not be added to these doctypes. "
"GL Entries will use the value of the existing field as the dimension value."
).format(
frappe.bold(self.fieldname),
", ".join(frappe.bold(d) for d in conflicting_doctypes),
),
title=_("Fieldname Conflict"),
indicator="orange",
)
def validate_dimension_defaults(self):
companies = []
for default in self.get("dimension_defaults"):

View File

@@ -38,16 +38,6 @@ frappe.ui.form.on("Accounts Settings", {
add_taxes_from_item_tax_template(frm) {
toggle_tax_settings(frm, "add_taxes_from_item_tax_template");
},
drop_ar_procedures: function (frm) {
frm.call({
doc: frm.doc,
method: "drop_ar_sql_procedures",
callback: function (r) {
frappe.show_alert(__("Procedures dropped"), 5);
},
});
},
});
function toggle_tax_settings(frm, field_name) {

View File

@@ -97,7 +97,6 @@
"receivable_payable_fetch_method",
"default_ageing_range",
"column_break_ntmi",
"drop_ar_procedures",
"legacy_section",
"ignore_is_opening_check_for_reporting",
"tab_break_dpet",
@@ -526,7 +525,7 @@
"fieldname": "receivable_payable_fetch_method",
"fieldtype": "Select",
"label": "Data Fetch Method",
"options": "Buffered Cursor\nUnBuffered Cursor\nRaw SQL"
"options": "Buffered Cursor\nUnBuffered Cursor"
},
{
"fieldname": "accounts_receivable_payable_tuning_section",
@@ -595,13 +594,6 @@
"fieldname": "column_break_ntmi",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.receivable_payable_fetch_method == \"Raw SQL\"",
"description": "Drops existing SQL Procedures and Function setup by Accounts Receivable report",
"fieldname": "drop_ar_procedures",
"fieldtype": "Button",
"label": "Drop Procedures"
},
{
"default": "0",
"fieldname": "fetch_valuation_rate_for_internal_transaction",
@@ -749,7 +741,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-04-22 01:38:42.418238",
"modified": "2026-05-18 12:16:33.679345",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -91,7 +91,7 @@ class AccountsSettings(Document):
merge_similar_account_heads: DF.Check
over_billing_allowance: DF.Currency
preview_mode: DF.Check
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor", "Raw SQL"]
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int
repost_allowed_types: DF.Table[RepostAllowedTypes]
@@ -212,13 +212,6 @@ class AccountsSettings(Document):
set_allow_on_submit_for_dimension_fields(doctypes)
@frappe.whitelist()
def drop_ar_sql_procedures(self):
from erpnext.accounts.report.accounts_receivable.accounts_receivable import InitSQLProceduresForAR
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.init_procedure_name}")
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.allocate_procedure_name}")
def toggle_accounting_dimension_sections(hide):
accounting_dimension_doctypes = frappe.get_hooks("accounting_dimension_doctypes")

View File

@@ -1,7 +1,7 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "format:Bank Statement Import on {creation}",
"beta": 1,
"creation": "2019-08-04 14:16:08.318714",
"doctype": "DocType",
"editable_grid": 1,
@@ -226,11 +226,11 @@
],
"hide_toolbar": 1,
"links": [],
"modified": "2025-06-11 02:23:22.159961",
"modified": "2026-05-31 00:41:11.251215",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Statement Import",
"naming_rule": "Expression",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{

View File

@@ -1,8 +1,8 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_events_in_timeline": 1,
"autoname": "naming_series:",
"beta": 1,
"creation": "2019-07-05 16:34:31.013238",
"doctype": "DocType",
"engine": "InnoDB",
@@ -400,7 +400,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2024-11-26 13:46:07.760867",
"modified": "2026-05-30 23:18:04.712528",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Dunning",
@@ -449,9 +449,10 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": [],
"title_field": "customer_name",
"track_changes": 1
}
}

View File

@@ -1,7 +1,7 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1,
"beta": 1,
"creation": "2019-12-04 04:59:08.003664",
"doctype": "DocType",
"editable_grid": 1,
@@ -107,7 +107,7 @@
"link_fieldname": "dunning_type"
}
],
"modified": "2024-03-27 13:08:19.584112",
"modified": "2026-05-30 23:18:20.740726",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Dunning Type",
@@ -151,8 +151,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -565,18 +565,19 @@ class FinancialQueryBuilder:
frappe.qb.from_(acb_table)
.select(
acb_table.account,
(acb_table.debit - acb_table.credit).as_("balance"),
Sum(acb_table.debit - acb_table.credit).as_("balance"),
)
.where(acb_table.company == self.company)
.where(acb_table.account.isin(account_names))
.where(acb_table.period_closing_voucher == closing_voucher)
.groupby(acb_table.account)
)
query = self._apply_standard_filters(query, acb_table, "Account Closing Balance")
results = self._execute_with_permissions(query, "Account Closing Balance")
for row in results:
closing_balances[row["account"]] = row["balance"]
closing_balances[row["account"]] = row["balance"] or 0.0
return closing_balances

View File

@@ -16,6 +16,7 @@ from erpnext.accounts.doctype.financial_report_template.test_financial_report_te
)
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.utils import get_currency_precision, get_fiscal_year
from erpnext.tests.utils import change_settings
class TestDependencyResolver(FinancialReportTemplateTestCase):
@@ -1950,6 +1951,104 @@ class TestFinancialQueryBuilder(FinancialReportTemplateTestCase):
jv_2023.cancel()
@change_settings("Accounts Settings", {"use_legacy_controller_for_pcv": 1})
def test_opening_balance_sums_acb_rows_across_dimensions(self):
"""
Account Closing Balance stores one row per (account, cost_center,
project, finance_book). The closing-balance fetch must sum all rows.
"""
company = "_Test Company"
cash_account = "_Test Cash - _TC"
sales_account = "Sales - _TC"
cc_1 = "_Test Cost Center - _TC"
cc_2 = "_Test Cost Center 2 - _TC"
docs = []
try:
jv_2023_cc1 = make_journal_entry(
account1=cash_account,
account2=sales_account,
amount=3000,
posting_date="2023-06-15",
cost_center=cc_1,
company=company,
submit=True,
)
docs.append(jv_2023_cc1)
jv_2023_cc2 = make_journal_entry(
account1=cash_account,
account2=sales_account,
amount=2000,
posting_date="2023-06-15",
cost_center=cc_2,
company=company,
submit=True,
)
docs.append(jv_2023_cc2)
fy_2023 = get_fiscal_year("2023-06-15", company=company)
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": "2023-12-31",
"period_start_date": fy_2023[1],
"period_end_date": fy_2023[2],
"company": company,
"fiscal_year": fy_2023[0],
"cost_center": cc_1,
"closing_account_head": "Deferred Revenue - _TC",
"remarks": "Test multi-dim PCV",
}
)
pcv.insert()
pcv.submit()
docs.append(pcv)
jv_2024 = make_journal_entry(
account1=cash_account,
account2=sales_account,
amount=100,
posting_date="2024-01-15",
cost_center=cc_1,
company=company,
submit=True,
)
docs.append(jv_2024)
filters = {
"company": company,
"from_fiscal_year": "2024",
"to_fiscal_year": "2024",
"period_start_date": "2024-01-01",
"period_end_date": "2024-03-31",
"filter_based_on": "Date Range",
"periodicity": "Monthly",
"ignore_closing_entries": True,
}
periods = [
{"key": "2024_jan", "from_date": "2024-01-01", "to_date": "2024-01-31"},
{"key": "2024_feb", "from_date": "2024-02-01", "to_date": "2024-02-29"},
{"key": "2024_mar", "from_date": "2024-03-01", "to_date": "2024-03-31"},
]
query_builder = FinancialQueryBuilder(filters, periods)
accounts = [
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
]
balances_data = query_builder.fetch_account_balances(accounts)
cash_data = balances_data.get(cash_account)
self.assertIsNotNone(cash_data, "Cash account must appear in results")
jan_cash = cash_data.get_period("2024_jan")
self.assertEqual(jan_cash.opening, 5000.0)
self.assertEqual(jan_cash.movement, 100.0)
self.assertEqual(jan_cash.closing, 5100.0)
finally:
self.cancel_docs(docs)
def test_opening_entries_roll_into_opening_after_period_closing(self):
"""
Sequence:

View File

@@ -9,6 +9,14 @@ from erpnext.tests.utils import ERPNextTestSuite
class FinancialReportTemplateTestCase(ERPNextTestSuite):
"""Utility class with common setup and helper methods for all test classes"""
def cancel_docs(self, docs):
"""Cancel submitted docs in reverse creation order to avoid dependency issues."""
for doc in reversed(docs):
if doc:
doc.reload()
if doc.docstatus == 1:
doc.cancel()
def setUp(self):
"""Set up test data"""
self.create_test_template()

View File

@@ -433,15 +433,17 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
accounts_add(doc, cdt, cdn) {
var row = frappe.get_doc(cdt, cdn);
row.exchange_rate = 1;
$.each(doc.accounts, function (i, d) {
if (d.account && d.party && d.party_type) {
row.account = d.account;
row.party = d.party;
row.party_type = d.party_type;
row.exchange_rate = d.exchange_rate;
}
});
if (!row.exchange_rate) row.exchange_rate = 1;
if (!row.account) {
$.each(doc.accounts, function (i, d) {
if (d.account && d.party && d.party_type) {
row.account = d.account;
row.party = d.party;
row.party_type = d.party_type;
row.exchange_rate = d.exchange_rate;
}
});
}
// set difference
if (doc.difference) {

View File

@@ -1,7 +1,7 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_copy": 1,
"beta": 1,
"creation": "2017-08-29 02:22:54.947711",
"doctype": "DocType",
"editable_grid": 1,
@@ -90,7 +90,7 @@
"hide_toolbar": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-31 01:47:20.360352",
"modified": "2026-05-30 23:18:48.691227",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool",

View File

@@ -29,6 +29,7 @@
{
"fieldname": "advance_account",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Advance Account",
"options": "Account"
}
@@ -36,14 +37,15 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:08.489183",
"modified": "2026-05-27 14:19:00.888437",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Party Account",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -710,31 +710,12 @@ frappe.ui.form.on("Payment Entry", {
if (!frm.doc.paid_from_account_currency || !frm.doc.company) return;
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.paid_from_account_currency == company_currency) {
frm.set_value("source_exchange_rate", 1);
} else if (frm.doc.paid_from) {
if (["Internal Transfer", "Pay"].includes(frm.doc.payment_type)) {
let company_currency = frappe.get_doc(":Company", frm.doc.company)?.default_currency;
frappe.call({
method: "erpnext.setup.utils.get_exchange_rate",
args: {
from_currency: frm.doc.paid_from_account_currency,
to_currency: company_currency,
transaction_date: frm.doc.posting_date,
},
callback: function (r, rt) {
frm.set_value("source_exchange_rate", r.message);
},
});
} else {
frm.events.set_current_exchange_rate(
frm,
"source_exchange_rate",
frm.doc.paid_from_account_currency,
company_currency
);
}
}
frm.events.set_current_exchange_rate(
frm,
"source_exchange_rate",
frm.doc.paid_from_account_currency,
company_currency
);
},
paid_to_account_currency: function (frm) {
@@ -766,49 +747,24 @@ frappe.ui.form.on("Payment Entry", {
posting_date: function (frm) {
frm.events.paid_from_account_currency(frm);
frm.events.paid_to_account_currency(frm);
},
source_exchange_rate: function (frm) {
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.paid_amount) {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
// target exchange rate should always be same as source if both account currencies is same
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
} else if (company_currency == frm.doc.paid_to_account_currency) {
frm.set_value("received_amount", frm.doc.base_paid_amount);
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
}
// set_unallocated_amount is called by below method,
// no need trigger separately
frm.events.set_total_allocated_amount(frm);
}
// Make read only if Accounts Settings doesn't allow stale rates
frm.set_df_property("source_exchange_rate", "read_only", erpnext.stale_rate_allowed() ? 0 : 1);
},
target_exchange_rate: function (frm) {
frm.set_paid_amount_based_on_received_amount = true;
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.received_amount) {
frm.set_value(
"base_received_amount",
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
);
if (frm.doc.base_received_amount && frm.doc.source_exchange_rate) {
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
if (
!frm.doc.source_exchange_rate &&
frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
) {
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
} else if (company_currency == frm.doc.paid_from_account_currency) {
frm.set_value("paid_amount", frm.doc.base_received_amount);
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
// target exchange rate should always be same as source if both account currencies is same
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
} else {
frm.set_value(
"paid_amount",
flt(frm.doc.base_paid_amount) / flt(frm.doc.source_exchange_rate)
);
}
// set_unallocated_amount is called by below method,
@@ -817,6 +773,32 @@ frappe.ui.form.on("Payment Entry", {
}
frm.set_paid_amount_based_on_received_amount = false;
// Make read only if Accounts Settings doesn't allow stale rates
frm.set_df_property("source_exchange_rate", "read_only", erpnext.stale_rate_allowed() ? 0 : 1);
},
target_exchange_rate: function (frm) {
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.base_paid_amount && frm.doc.target_exchange_rate) {
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
if (
!frm.doc.source_exchange_rate &&
frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
) {
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
} else {
frm.set_value(
"received_amount",
flt(frm.doc.base_received_amount) / flt(frm.doc.target_exchange_rate)
);
}
// set_unallocated_amount is called by below method,
// no need trigger separately
frm.events.set_total_allocated_amount(frm);
}
// Make read only if Accounts Settings doesn't allow stale rates
frm.set_df_property("target_exchange_rate", "read_only", erpnext.stale_rate_allowed() ? 0 : 1);
},
@@ -825,11 +807,14 @@ frappe.ui.form.on("Payment Entry", {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (!frm.doc.received_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("received_amount", frm.doc.paid_amount);
} else if (company_currency == frm.doc.paid_to_account_currency) {
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
if (company_currency == frm.doc.paid_to_account_currency) {
frm.set_value("received_amount", frm.doc.base_paid_amount);
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
} else if (frm.doc.target_exchange_rate) {
frm.set_value(
"received_amount",
flt(frm.doc.base_paid_amount) / flt(frm.doc.target_exchange_rate)
);
}
}
frm.trigger("reset_received_amount");
@@ -846,15 +831,14 @@ frappe.ui.form.on("Payment Entry", {
);
if (!frm.doc.paid_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("paid_amount", frm.doc.received_amount);
if (frm.doc.target_exchange_rate) {
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
}
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
} else if (company_currency == frm.doc.paid_from_account_currency) {
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
if (company_currency == frm.doc.paid_from_account_currency) {
frm.set_value("paid_amount", frm.doc.base_received_amount);
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
} else if (frm.doc.source_exchange_rate) {
frm.set_value(
"paid_amount",
flt(frm.doc.base_received_amount) / flt(frm.doc.source_exchange_rate)
);
}
}

View File

@@ -322,7 +322,7 @@
"reqd": 1
},
{
"depends_on": "doc.received_amount",
"depends_on": "eval:doc.received_amount;",
"fieldname": "base_received_amount",
"fieldtype": "Currency",
"label": "Received Amount (Company Currency)",
@@ -795,7 +795,7 @@
"table_fieldname": "payment_entries"
}
],
"modified": "2026-03-09 17:15:30.453920",
"modified": "2026-05-15 13:31:01.166010",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry",

View File

@@ -1238,9 +1238,9 @@ class PaymentEntry(AccountsController):
else:
remarks = [
_("Amount {0} {1} {2} {3}").format(
_(self.paid_to_account_currency)
_(self.paid_from_account_currency)
if self.payment_type == "Receive"
else _(self.paid_from_account_currency),
else _(self.paid_to_account_currency),
self.paid_amount if self.payment_type == "Receive" else self.received_amount,
_("received from") if self.payment_type == "Receive" else _("paid to"),
self.party,
@@ -1256,7 +1256,7 @@ class PaymentEntry(AccountsController):
for d in self.get("references"):
if d.allocated_amount:
remarks.append(
_("Amount {0} {1} against {2} {3}").format(
_("Amount {0} {1} adjusted against {2} {3}").format(
_(self.party_account_currency),
d.allocated_amount,
d.reference_doctype,
@@ -1267,7 +1267,7 @@ class PaymentEntry(AccountsController):
for d in self.get("deductions"):
if d.amount:
remarks.append(
_("Amount {0} {1} deducted against {2}").format(
_("Amount {0} {1} as adjustment to {2}").format(
_(self.company_currency), d.amount, d.account
)
)

View File

@@ -1,20 +1,4 @@
frappe.listview_settings["Payment Entry"] = {
add_fields: ["unallocated_amount", "docstatus"],
get_indicator: function (doc) {
if (doc.docstatus === 2) {
return [__("Cancelled"), "red", "docstatus,=,2"];
}
if (doc.docstatus === 0) {
return [__("Draft"), "orange", "docstatus,=,0"];
}
if (flt(doc.unallocated_amount) > 0) {
return [__("Unreconciled"), "orange", "docstatus,=,1|unallocated_amount,>,0"];
}
return [__("Reconciled"), "green", "docstatus,=,1|unallocated_amount,=,0"];
},
onload: function (listview) {
if (listview.page.fields_dict.party_type) {
listview.page.fields_dict.party_type.get_query = function () {

View File

@@ -3,7 +3,8 @@
import frappe
from frappe import qb
from frappe.utils import nowdate
from frappe.query_builder.functions import Count, Sum
from frappe.utils import add_days, nowdate
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
@@ -90,6 +91,7 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
posting_date = nowdate()
sinv = create_sales_invoice(
posting_date=posting_date,
qty=qty,
rate=rate,
company=self.company,
@@ -531,3 +533,82 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
# with references removed, deletion should be possible
so.delete()
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, so.doctype, so.name)
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{"enable_immutable_ledger": 1},
)
def test_reverse_entries_on_cancel_for_immutable_ledger(self):
invoice_posting_date = add_days(nowdate(), -5)
gle = qb.DocType("GL Entry")
ple = qb.DocType("Payment Ledger Entry")
si = self.create_sales_invoice(qty=1, rate=100, posting_date=invoice_posting_date)
gles_before = (
qb.from_(gle)
.select(
Count(gle.name),
)
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
.run()[0][0]
)
ples_before = (
qb.from_(ple)
.select(
Count(ple.name),
)
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0)))
.run()[0][0]
)
si.cancel()
gles_after = (
qb.from_(gle)
.select(Count(gle.account))
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
.run()[0][0]
)
self.assertEqual(gles_after, gles_before * 2)
ples_after = (
qb.from_(ple)
.select(
Count(ple.name),
)
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0)))
.run()[0][0]
)
self.assertEqual(ples_after, ples_before * 2)
# assert debit/credit are reversed
gl_entries = (
qb.from_(gle)
.select(gle.account, Sum(gle.debit).as_("total_debit"), Sum(gle.credit).as_("total_credit"))
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
.groupby(gle.account)
.run(as_dict=True)
)
for gl in gl_entries:
with self.subTest(gl=gl):
self.assertEqual(gl.total_debit, gl.total_credit)
# assert amounts are reversed
pl_entries = (
qb.from_(ple)
.select(ple.account, Sum(ple.amount).as_("total_amount"))
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked == 0))
.groupby(ple.account)
.run(as_dict=True)
)
for pl in pl_entries:
with self.subTest(pl=pl):
self.assertEqual(pl.total_amount, 0)
self.assertFalse(
frappe.db.exists(
"Payment Ledger Entry",
{"voucher_type": si.doctype, "voucher_no": si.name, "delinked": 1},
)
)

View File

@@ -1293,11 +1293,16 @@ def get_open_payment_requests_query(
)
return [
(
pr.name,
_("<strong>Grand Total:</strong> {0}").format(pr.grand_total),
_("<strong>Outstanding Amount:</strong> {0}").format(pr.outstanding_amount),
)
{
"value": pr.name,
"description": ", ".join(
[
_("<strong>Grand Total:</strong> {0}").format(pr.grand_total),
_("<strong>Outstanding Amount:</strong> {0}").format(pr.outstanding_amount),
]
),
"description_html": True,
}
for pr in open_payment_requests
]

View File

@@ -7,6 +7,7 @@ from frappe.utils import today
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.general_ledger import make_reverse_gl_entries
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
@@ -333,6 +334,48 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
return pcv
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{"enable_immutable_ledger": 1},
)
def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self):
company = create_company()
cost_center = create_cost_center("Test Cost Center 1")
jv = make_journal_entry(
posting_date="2021-03-15",
amount=400,
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center,
company=company,
save=False,
)
jv.company = company
jv.save()
jv.submit()
self.make_period_closing_voucher(posting_date="2021-03-31")
# Passed posting_date is after PCV end date, so cancellation should not fail.
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
posting_date="2022-01-01",
)
totals_after_cancel = frappe.db.sql(
"""
select sum(debit) as total_debit, sum(credit) as total_credit
from `tabGL Entry`
where voucher_type=%s and voucher_no=%s and is_cancelled=0
""",
("Journal Entry", jv.name),
as_dict=True,
)[0]
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
def create_company():
company = frappe.get_doc(

View File

@@ -26,8 +26,6 @@
"due_date",
"amended_from",
"return_against",
"section_break_clmv",
"title",
"accounting_dimensions_section",
"project",
"dimension_col_break",
@@ -172,6 +170,7 @@
"is_discounted",
"col_break23",
"status",
"title",
"more_info",
"debit_to",
"party_account_currency",
@@ -1625,10 +1624,6 @@
"fieldtype": "Section Break",
"label": "Auto Repeat"
},
{
"fieldname": "section_break_clmv",
"fieldtype": "Section Break"
},
{
"allow_on_submit": 1,
"fieldname": "title",
@@ -1641,7 +1636,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2026-05-01 02:37:30.580568",
"modified": "2026-05-28 12:22:50.253090",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

@@ -209,15 +209,14 @@ class POSProfile(Document):
def set_defaults(self, include_current_pos=True):
frappe.defaults.clear_default("is_pos")
if not include_current_pos:
condition = " where pfu.name != '%s' and pfu.default = 1 " % self.name.replace("'", "'")
else:
condition = " where pfu.default = 1 "
pfu = frappe.qb.DocType("POS Profile User")
pos_view_users = frappe.db.sql_list(
f"""select pfu.user
from `tabPOS Profile User` as pfu {condition}"""
)
query = frappe.qb.from_(pfu).select(pfu.user).where(pfu.default == 1)
if not include_current_pos:
query = query.where(pfu.name != self.name)
pos_view_users = query.run(as_list=1, pluck=True)
for user in pos_view_users:
if user:

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "format:Process-PCV-{###}",
"creation": "2025-09-25 15:44:03.534699",
"doctype": "DocType",
@@ -7,11 +8,13 @@
"field_order": [
"parent_pcv",
"status",
"amended_from",
"section_normal_balances",
"p_l_closing_balance",
"normal_balances",
"bs_closing_balance",
"z_opening_balances",
"amended_from"
"normal_balances",
"section_opening_balances",
"z_opening_balances"
],
"fields": [
{
@@ -64,17 +67,27 @@
"fieldname": "bs_closing_balance",
"fieldtype": "JSON",
"label": "Balance Sheet Closing Balance"
},
{
"fieldname": "section_normal_balances",
"fieldtype": "Tab Break",
"label": "Normal Balances"
},
{
"fieldname": "section_opening_balances",
"fieldtype": "Tab Break",
"label": "Opening Balances"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-11-05 11:40:24.996403",
"modified": "2026-06-01 12:16:37.374412",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Period Closing Voucher",
"naming_rule": "Expression",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{

View File

@@ -36,8 +36,8 @@ class ProcessPeriodClosingVoucher(Document):
parent_pcv: DF.Link
status: DF.Literal["Queued", "Running", "Paused", "Completed", "Cancelled"]
z_opening_balances: DF.Table[ProcessPeriodClosingVoucherDetail]
# end: auto-generated types
def on_discard(self):
self.db_set("status", "Cancelled")
@@ -72,8 +72,8 @@ class ProcessPeriodClosingVoucher(Document):
pcv = frappe.get_doc("Period Closing Voucher", self.parent_pcv)
if pcv.is_first_period_closing_voucher():
gl = qb.DocType("GL Entry")
min = qb.from_(gl).select(Min(gl.posting_date)).where(gl.company.eq(pcv.company)).run()[0][0]
max = qb.from_(gl).select(Max(gl.posting_date)).where(gl.company.eq(pcv.company)).run()[0][0]
min = qb.from_(gl).select(Min(gl.posting_date)).run()[0][0]
max = qb.from_(gl).select(Max(gl.posting_date)).run()[0][0]
dates = self.get_dates(get_datetime(min), get_datetime(max))
for x in dates:
@@ -93,12 +93,16 @@ class ProcessPeriodClosingVoucher(Document):
def start_pcv_processing(docname: str):
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
if normal_balances := frappe.db.get_all(
"Process Period Closing Voucher Detail",
filters={"parent": docname, "status": "Queued"},
fields=["processing_date", "report_type", "parentfield"],
order_by="parentfield, idx, processing_date",
limit=4,
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if normal_balances := (
qb.from_(ppcvd)
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(4)
.for_update(skip_locked=True)
.run(as_dict=True)
):
if not is_scheduler_inactive():
for x in normal_balances:
@@ -133,9 +137,10 @@ def pause_pcv_processing(docname: str):
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
# If a date is stuck in 'Running' state, this will allow it to procced.
if queued_dates := frappe.db.get_all(
"Process Period Closing Voucher Detail",
filters={"parent": docname, "status": "Queued"},
filters={"parent": docname, "status": ["in", ["Queued", "Running"]]},
pluck="name",
):
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
@@ -169,6 +174,9 @@ def resume_pcv_processing(docname: str):
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
qb.update(ppcvd).set(ppcvd.status, "Queued").where(ppcvd.name.isin(paused_dates)).run()
start_pcv_processing(docname)
else:
# If a parent doc is stuck in 'Running' state, will allow it to proceed.
schedule_next_date(docname)
def update_default_dimensions(dimension_fields, gl_entry, dimension_values):
@@ -238,12 +246,15 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
@frappe.whitelist()
def schedule_next_date(docname: str):
if to_process := frappe.db.get_all(
"Process Period Closing Voucher Detail",
filters={"parent": docname, "status": "Queued"},
fields=["processing_date", "report_type", "parentfield"],
order_by="parentfield, idx, processing_date",
limit=1,
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if to_process := (
qb.from_(ppcvd)
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(1)
.for_update(skip_locked=True)
.run(as_dict=True)
):
if not is_scheduler_inactive():
frappe.db.set_value(
@@ -281,7 +292,21 @@ def schedule_next_date(docname: str):
)
# Ensure both normal and opening balances are processed for all dates
if total_no_of_dates == completed:
summarize_and_post_ledger_entries(docname)
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
is_job_running,
)
job_name = f"summarize_{docname}"
if not is_job_running(job_name):
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
queue="long",
timeout="3600",
is_async=True,
job_name=job_name,
enqueue_after_commit=True,
docname=docname,
)
def make_dict_json_compliant(dimension_wise_balance) -> dict:
@@ -537,6 +562,9 @@ def process_individual_date(docname: str, date, report_type, parentfield):
if parentfield == "z_opening_balances":
query = query.where(gle.is_opening.eq("Yes"))
else:
# Keep balances aligned with legacy PCV logic (non-opening transactions only)
query = query.where(gle.is_opening.eq("No"))
query = query.groupby(gle.account)
for dim in dimensions:

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
// render
frappe.listview_settings["Process Period Closing Voucher"] = {
add_fields: ["status"],
get_indicator: function (doc) {
const status_colors = {
Queued: "blue",
Running: "orange",
Paused: "gray",
Completed: "green",
Cancelled: "red",
};
return [__(doc.status), status_colors[doc.status], "status,=," + doc.status];
},
};

View File

@@ -1,4 +1,173 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from frappe.utils import today
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher import (
process_individual_date,
)
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
def setUp(self):
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 0)
self.company = "_Test Company"
def make_period_closing_voucher(self, posting_date, submit=True):
fy = get_fiscal_year(posting_date, company="_Test Company")
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": posting_date or today(),
"period_start_date": fy[1],
"period_end_date": fy[2],
"company": self.company,
"fiscal_year": fy[0],
"closing_account_head": "Retained Earnings - _TC",
"remarks": "closing",
}
)
pcv.insert()
if submit:
pcv.submit()
return pcv
def make_process_pcv(self):
self.pcv = self.make_period_closing_voucher(posting_date=today(), submit=False)
ppcv = frappe.get_doc(
{
"doctype": "Process Period Closing Voucher",
"parent_pcv": self.pcv.name,
}
)
ppcv.save()
return ppcv
def set_processing_date_status(self, date, ppcv, rpt_type, parentfield, status):
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
"status",
status,
)
def get_processing_date_closing_balance(self, date, ppcv, rpt_type, parentfield):
return frappe.db.get_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
"closing_balance",
)
def test_opening_balance_double_counting(self):
ppcv = self.make_process_pcv()
self.assertEqual(self.pcv.is_first_period_closing_voucher(), True)
opening_jv = make_journal_entry(
posting_date=today(),
amount=10,
account1="Cash - _TC",
account2="Debtors - _TC",
company=self.company,
save=False,
)
opening_jv.accounts[1].party_type = "Customer"
opening_jv.accounts[1].party = "_Test Customer"
opening_jv.is_opening = "Yes"
opening_jv.save()
opening_jv.submit()
jv = make_journal_entry(
posting_date=today(),
amount=120,
account1="Debtors - _TC",
account2="Sales - _TC",
company=self.company,
save=False,
)
jv.accounts[0].party_type = "Customer"
jv.accounts[0].party = "_Test Customer"
jv.save()
jv.submit()
# P&L balance
parentfield = "normal_balances"
rpt_type = "Profit and Loss"
# status has to be set to 'Running' for logic to run
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
self.assertEqual(len(bal), 1)
expected_pl = {
"account": "Sales - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit": 0.0,
"credit": 120.0,
"debit_in_account_currency": 0.0,
"credit_in_account_currency": 120.0,
}
for k in expected_pl.keys():
with self.subTest(k):
self.assertEqual(expected_pl[k], bal[0][k])
# Balance sheet balance
rpt_type = "Balance Sheet"
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
self.assertEqual(len(bal), 1)
expected_bs = {
"account": "Debtors - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit": 120.0,
"credit": 0.0,
"debit_in_account_currency": 120.0,
"credit_in_account_currency": 0.0,
}
for k in expected_bs.keys():
with self.subTest(k):
self.assertEqual(expected_bs[k], bal[0][k])
# Opening balance
parentfield = "z_opening_balances"
rpt_type = "Balance Sheet"
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
self.assertEqual(len(bal), 2)
opening_cash = next(x for x in bal if x["account"] == "Cash - _TC")
expected_opening_cash = {
"account": "Cash - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit": 10.0,
"credit": 0.0,
"debit_in_account_currency": 10.0,
"credit_in_account_currency": 0.0,
"account_currency": "INR",
}
for k in expected_opening_cash.keys():
with self.subTest(k):
self.assertEqual(expected_opening_cash[k], opening_cash[k])
opening_debtors = next(x for x in bal if x["account"] == "Debtors - _TC")
expected_opening_debtors = {
"account": "Debtors - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit": 0.0,
"credit": 10.0,
"debit_in_account_currency": 0.0,
"credit_in_account_currency": 10.0,
"account_currency": "INR",
}
for k in expected_opening_debtors.keys():
with self.subTest(k):
self.assertEqual(expected_opening_debtors[k], opening_debtors[k])

View File

@@ -28,8 +28,6 @@
"update_billed_amount_in_purchase_receipt",
"apply_tds",
"amended_from",
"section_break_ecfi",
"title",
"supplier_invoice_details",
"bill_no",
"column_break_15",
@@ -202,6 +200,7 @@
"hold_comment",
"additional_info_section",
"is_internal_supplier",
"title",
"represents_company",
"supplier_group",
"sender",
@@ -1684,10 +1683,6 @@
"fieldname": "automation_section",
"fieldtype": "Section Break",
"label": "Automation"
},
{
"fieldname": "section_break_ecfi",
"fieldtype": "Section Break"
}
],
"grid_page_length": 50,
@@ -1695,7 +1690,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-05-04 10:10:11.717131",
"modified": "2026-05-28 12:36:55.215363",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -2962,6 +2962,52 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pr = make_purchase_receipt_from_pi(pi.name)
self.assertFalse(pr.items)
@ERPNextTestSuite.change_settings("Accounts Settings", {"enable_common_party_accounting": True})
def test_purchase_invoice_return_common_party_je_has_no_negative_amounts(self):
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
make_customer,
)
from erpnext.accounts.doctype.party_link.party_link import create_party_link
from erpnext.controllers.sales_and_purchase_return import make_return_doc
customer = make_customer(customer="_Test Common Party Return PI")
supplier = create_supplier(supplier_name="_Test Common Party Return PI").name
# Supplier must be secondary so get_common_party_link finds it via the PI's party_type
party_link = create_party_link("Customer", customer, supplier)
pi = make_purchase_invoice(supplier=supplier, parent_cost_center="_Test Cost Center - _TC")
return_pi = make_return_doc(pi.doctype, pi.name)
return_pi.submit()
# JE for the return should credit the supplier (secondary/reconciliation) account
# and debit the customer (primary) account — all positive amounts
jv_accounts = frappe.get_all(
"Journal Entry Account",
filters={"reference_type": return_pi.doctype, "reference_name": return_pi.name, "docstatus": 1},
fields=["debit_in_account_currency", "credit_in_account_currency", "account"],
)
self.assertTrue(jv_accounts, "Expected a Journal Entry for the return invoice")
for row in jv_accounts:
self.assertGreaterEqual(
row.debit_in_account_currency,
0,
f"Negative debit on account {row.account}",
)
self.assertGreaterEqual(
row.credit_in_account_currency,
0,
f"Negative credit on account {row.account}",
)
# Supplier (secondary) account must be credited, not debited
supplier_row = next(r for r in jv_accounts if r.account == pi.credit_to)
self.assertGreater(supplier_row.credit_in_account_currency, 0)
self.assertEqual(supplier_row.debit_in_account_currency, 0)
party_link.delete()
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(

View File

@@ -33,8 +33,6 @@
"is_created_using_pos",
"pos_closing_entry",
"has_subcontracted",
"section_break_sgnf",
"title",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
@@ -232,6 +230,7 @@
"status",
"remarks",
"customer_group",
"title",
"column_break_imbx",
"is_internal_customer",
"represents_company",
@@ -1919,7 +1918,7 @@
{
"default": "0",
"depends_on": "eval: !doc.is_return",
"description": "Issue a debit note with 0 qty against an existing Sales Invoice",
"description": "Issue a debit note against an existing Sales Invoice to adjust the rate. The quantity will be retained from the original invoice.",
"fieldname": "is_debit_note",
"fieldtype": "Check",
"label": "Is Rate Adjustment Entry (Debit Note)"
@@ -2333,10 +2332,6 @@
"fieldtype": "Section Break",
"label": "Automation"
},
{
"fieldname": "section_break_sgnf",
"fieldtype": "Section Break"
},
{
"allow_on_submit": 1,
"fieldname": "title",
@@ -2357,7 +2352,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-05-01 02:37:29.742764",
"modified": "2026-05-28 12:15:12.486443",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -969,9 +969,6 @@ class SalesInvoice(SellingController):
if selling_price_list:
self.set("selling_price_list", selling_price_list)
if not for_validate:
self.update_stock = cint(pos.get("update_stock"))
# set pos values in items
for item in self.get("items"):
if item.get("item_code"):
@@ -982,6 +979,10 @@ class SalesInvoice(SellingController):
if (not for_validate) or (for_validate and not item.get(fname)):
item.set(fname, val)
if not for_validate:
dn_flag = any(d.get("dn_detail") for d in self.get("items"))
self.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
# fetch terms
if self.tc_name and not self.terms:
self.terms = frappe.db.get_value("Terms and Conditions", self.tc_name, "terms")
@@ -993,7 +994,7 @@ class SalesInvoice(SellingController):
return pos
def get_company_abbr(self):
return frappe.db.sql("select abbr from tabCompany where name=%s", self.company)[0][0]
return frappe.db.get_value("Company", self.company, "abbr")
def validate_debit_to_acc(self):
if not self.debit_to:
@@ -1033,11 +1034,7 @@ class SalesInvoice(SellingController):
def clear_unallocated_mode_of_payments(self):
self.set("payments", self.get("payments", {"amount": ["not in", [0, None, ""]]}))
frappe.db.sql(
"""delete from `tabSales Invoice Payment` where parent = %s
and amount = 0""",
self.name,
)
frappe.db.delete("Sales Invoice Payment", filters={"parent": self.name, "amount": 0})
def validate_with_previous_doc(self):
super().validate_with_previous_doc(
@@ -1133,12 +1130,20 @@ class SalesInvoice(SellingController):
def validate_proj_cust(self):
"""check for does customer belong to same project as entered.."""
if self.project and self.customer:
res = frappe.db.sql(
"""select name from `tabProject`
where name = %s and (customer = %s or customer is null or customer = '')""",
(self.project, self.customer),
Project = frappe.qb.DocType("Project")
query = (
frappe.qb.from_(Project)
.select(Project.name)
.where(Project.name == self.project)
.where(
(Project.customer == self.customer)
| (Project.customer.isnull())
| (Project.customer == "")
)
)
if not res:
if not query.run():
throw(_("Customer {0} does not belong to project {1}").format(self.customer, self.project))
def validate_pos(self):
@@ -1363,19 +1368,28 @@ class SalesInvoice(SellingController):
self.total_billing_hours = timesheet_sum("billing_hours")
def get_warehouse(self):
user_pos_profile = frappe.db.sql(
"""select name, warehouse from `tabPOS Profile`
where ifnull(user,'') = %s and company = %s""",
(frappe.session["user"], self.company),
POSProfile = frappe.qb.DocType("POS Profile")
user_query = (
frappe.qb.from_(POSProfile)
.select(POSProfile.name, POSProfile.warehouse)
.where(POSProfile.company == self.company)
.where(
(POSProfile.user == frappe.session["user"])
| ((POSProfile.user.isnull() | (POSProfile.user == "")) & (frappe.session["user"] == ""))
)
)
user_pos_profile = user_query.run()
warehouse = user_pos_profile[0][1] if user_pos_profile else None
if not warehouse:
global_pos_profile = frappe.db.sql(
"""select name, warehouse from `tabPOS Profile`
where (user is null or user = '') and company = %s""",
self.company,
global_query = (
frappe.qb.from_(POSProfile)
.select(POSProfile.name, POSProfile.warehouse)
.where(POSProfile.company == self.company)
.where(POSProfile.user.isnull() | (POSProfile.user == ""))
)
global_pos_profile = global_query.run()
if global_pos_profile:
warehouse = global_pos_profile[0][1]
@@ -1586,6 +1600,12 @@ class SalesInvoice(SellingController):
self.make_internal_transfer_gl_entries(gl_entries)
self.make_item_gl_entries(gl_entries)
disable_sdbnb_in_sr = frappe.get_cached_value("Company", self.company, "disable_sdbnb_in_sr")
if not (self.is_return and disable_sdbnb_in_sr):
self.stock_delivered_but_not_billed_gl_entries(gl_entries)
self.make_precision_loss_gl_entry(gl_entries)
self.make_discount_gl_entries(gl_entries)
@@ -1603,6 +1623,81 @@ class SalesInvoice(SellingController):
self.set_transaction_currency_and_rate_in_gl_map(gl_entries)
return gl_entries
def stock_delivered_but_not_billed_gl_entries(self, gl_entries):
if self.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(self.company)):
return
for item in self.get("items"):
if not item.delivery_note and not item.dn_detail:
continue
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
continue
dn_expense_account = frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "expense_account"
)
if (
not dn_expense_account
or frappe.get_cached_value("Account", dn_expense_account, "account_type")
!= "Stock Delivered But Not Billed"
or not item.expense_account
or dn_expense_account == item.expense_account
):
continue
delivery_note = item.delivery_note or frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "parent"
)
if not delivery_note:
continue
item_g = frappe.get_cached_value(
"Stock Ledger Entry",
{
"voucher_no": delivery_note,
"voucher_detail_no": item.dn_detail,
"item_code": item.item_code,
"is_cancelled": 0,
},
["stock_value_difference", "actual_qty"],
as_dict=True,
)
if not item_g or not flt(item_g.actual_qty):
continue
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
valuation_amount = valuation_rate * item.stock_qty
dn_account_currency = get_account_currency(dn_expense_account)
item_account_currency = get_account_currency(item.expense_account)
gl_entries.append(
self.get_gl_dict(
{
"account": dn_expense_account,
"against": item.expense_account,
"credit": flt(valuation_amount),
"credit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
dn_account_currency,
item=item,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": dn_expense_account,
"debit": flt(valuation_amount),
"debit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
item_account_currency,
item=item,
)
)
def make_customer_gl_entry(self, gl_entries):
# Checked both rounding_adjustment and rounded_total
# because rounded_total had value even before introduction of posting GLE based on rounded total
@@ -2024,15 +2119,24 @@ class SalesInvoice(SellingController):
def update_billing_status_in_dn(self, update_modified=True):
if self.is_return and not self.update_billed_amount_in_delivery_note:
return
updated_delivery_notes = []
SalesInvoiceItem = frappe.qb.DocType("Sales Invoice Item")
from frappe.query_builder.functions import Coalesce, Sum
for d in self.get("items"):
if d.dn_detail:
billed_amt = frappe.db.sql(
"""select sum(amount) from `tabSales Invoice Item`
where dn_detail=%s and docstatus=1""",
d.dn_detail,
query = (
frappe.qb.from_(SalesInvoiceItem)
.select(Coalesce(Sum(SalesInvoiceItem.amount), 0))
.where(SalesInvoiceItem.dn_detail == d.dn_detail)
.where(SalesInvoiceItem.docstatus == 1)
)
billed_amt = billed_amt and billed_amt[0][0] or 0
res = query.run()
billed_amt = res[0][0] if res else 0
frappe.db.set_value(
"Delivery Note Item",
d.dn_detail,
@@ -2306,19 +2410,21 @@ def is_overdue(doc, total):
def get_discounting_status(sales_invoice):
status = None
invoice_discounting_list = frappe.db.sql(
"""
select status
from `tabInvoice Discounting` id, `tabDiscounted Invoice` d
where
id.name = d.parent
and d.sales_invoice=%s
and id.docstatus=1
and status in ('Disbursed', 'Settled')
""",
sales_invoice,
InvoiceDiscounting = frappe.qb.DocType("Invoice Discounting")
DiscountedInvoice = frappe.qb.DocType("Discounted Invoice")
query = (
frappe.qb.from_(InvoiceDiscounting)
.join(DiscountedInvoice)
.on(InvoiceDiscounting.name == DiscountedInvoice.parent)
.select(InvoiceDiscounting.status)
.where(DiscountedInvoice.sales_invoice == sales_invoice)
.where(InvoiceDiscounting.docstatus == 1)
.where(InvoiceDiscounting.status.isin(["Disbursed", "Settled"]))
)
invoice_discounting_list = query.run()
for d in invoice_discounting_list:
status = d[0]
if status == "Disbursed":
@@ -3036,15 +3142,22 @@ def update_multi_mode_option(doc, pos_profile):
def get_all_mode_of_payments(doc):
return frappe.db.sql(
"""
select mpa.default_account, mpa.parent, mp.type as type
from `tabMode of Payment Account` mpa,`tabMode of Payment` mp
where mpa.parent = mp.name and mpa.company = %(company)s and mp.enabled = 1""",
{"company": doc.company},
as_dict=1,
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(ModeOfPaymentAccount)
.join(ModeOfPayment)
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
.select(
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
)
.where(ModeOfPaymentAccount.company == doc.company)
.where(ModeOfPayment.enabled == 1)
)
return query.run(as_dict=1)
def get_mode_of_payments_info(mode_of_payments, company):
data = frappe.db.sql(
@@ -3136,37 +3249,36 @@ def create_dunning(
def check_if_return_invoice_linked_with_payment_entry(self):
# If a Return invoice is linked with payment entry along with other invoices,
# the cancellation of the Return causes allocated amount to be greater than paid
if not frappe.get_single_value("Accounts Settings", "unlink_payment_on_cancellation_of_invoice"):
return
payment_entries = []
if self.is_return and self.return_against:
invoice = self.return_against
else:
invoice = self.name
payment_entries = frappe.db.sql_list(
"""
SELECT
t1.name
FROM
`tabPayment Entry` t1, `tabPayment Entry Reference` t2
WHERE
t1.name = t2.parent
and t1.docstatus = 1
and t2.reference_name = %s
and t2.allocated_amount < 0
""",
invoice,
PaymentEntry = frappe.qb.DocType("Payment Entry")
PaymentEntryReference = frappe.qb.DocType("Payment Entry Reference")
query = (
frappe.qb.from_(PaymentEntry)
.join(PaymentEntryReference)
.on(PaymentEntry.name == PaymentEntryReference.parent)
.select(PaymentEntry.name)
.where(PaymentEntry.docstatus == 1)
.where(PaymentEntryReference.reference_name == invoice)
.where(PaymentEntryReference.allocated_amount < 0)
)
payment_entries = query.run(pluck=True)
links_to_pe = []
if payment_entries:
for payment in payment_entries:
payment_entry = frappe.get_doc("Payment Entry", payment)
if len(payment_entry.references) > 1:
links_to_pe.append(payment_entry.name)
if links_to_pe:
payment_entries_link = [
get_link_to_form("Payment Entry", name, label=name) for name in links_to_pe

View File

@@ -27,6 +27,15 @@ frappe.listview_settings["Sales Invoice"] = {
"Partly Paid": "yellow",
"Internal Transfer": "darkgrey",
};
if (doc.status === "Credit Note Issued" && flt(doc.outstanding_amount) === 0) {
return [
__("Settled with Credit Note"),
"green",
`status,=,Credit Note Issued|outstanding_amount,=,0`,
];
}
return [__(doc.status), status_colors[doc.status], "status,=," + doc.status];
},
right_column: "grand_total",

View File

@@ -3319,6 +3319,52 @@ class TestSalesInvoice(ERPNextTestSuite):
party_link.delete()
frappe.db.set_single_value("Accounts Settings", "enable_common_party_accounting", 0)
@ERPNextTestSuite.change_settings("Accounts Settings", {"enable_common_party_accounting": True})
def test_sales_invoice_return_common_party_je_has_no_negative_amounts(self):
from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import (
make_customer,
)
from erpnext.accounts.doctype.party_link.party_link import create_party_link
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
from erpnext.controllers.sales_and_purchase_return import make_return_doc
customer = make_customer(customer="_Test Common Party Return SI")
supplier = create_supplier(supplier_name="_Test Common Party Return SI").name
party_link = create_party_link("Supplier", supplier, customer)
si = create_sales_invoice(customer=customer, parent_cost_center="_Test Cost Center - _TC")
return_si = make_return_doc(si.doctype, si.name)
return_si.submit()
# JE for the return should credit the supplier (primary/advance) account
# and debit the customer (secondary/reconciliation) account — all positive amounts
jv_accounts = frappe.get_all(
"Journal Entry Account",
filters={"reference_type": return_si.doctype, "reference_name": return_si.name, "docstatus": 1},
fields=["debit_in_account_currency", "credit_in_account_currency", "account"],
)
self.assertTrue(jv_accounts, "Expected a Journal Entry for the return invoice")
for row in jv_accounts:
self.assertGreaterEqual(
row.debit_in_account_currency,
0,
f"Negative debit on account {row.account}",
)
self.assertGreaterEqual(
row.credit_in_account_currency,
0,
f"Negative credit on account {row.account}",
)
# Customer (secondary) account must be debited, not credited
customer_row = next(r for r in jv_accounts if r.account == return_si.debit_to)
self.assertGreater(customer_row.debit_in_account_currency, 0)
self.assertEqual(customer_row.credit_in_account_currency, 0)
party_link.delete()
def test_payment_statuses(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry

View File

@@ -947,7 +947,8 @@
"fieldtype": "Currency",
"label": "Distributed Discount Amount",
"options": "currency",
"print_hide": 1
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "available_quantity_section",
@@ -1016,7 +1017,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-02-24 14:37:16.853941",
"modified": "2026-05-29 12:23:28.259905",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",

View File

@@ -14,6 +14,7 @@ from frappe.utils import cstr
from frappe.utils.nestedset import get_root_of
from erpnext.setup.doctype.customer_group.customer_group import get_parent_customer_groups
from erpnext.setup.doctype.supplier_group.supplier_group import get_parent_supplier_groups
class IncorrectCustomerGroup(frappe.ValidationError):
@@ -176,38 +177,44 @@ def get_party_details(party: str | None, party_type: str, args: dict | None = No
def get_tax_template(posting_date, args):
"""Get matching tax rule"""
args = frappe._dict(args)
conditions = []
TaxRule = DocType("Tax Rule")
query = frappe.qb.from_(TaxRule).select("*")
if posting_date:
conditions.append(
f"""(from_date is null or from_date <= '{posting_date}')
and (to_date is null or to_date >= '{posting_date}')"""
query = query.where(
(TaxRule.from_date.isnull() | (TaxRule.from_date <= posting_date))
& (TaxRule.to_date.isnull() | (TaxRule.to_date >= posting_date))
)
else:
conditions.append("(from_date is null) and (to_date is null)")
query = query.where(TaxRule.from_date.isnull() & TaxRule.to_date.isnull())
conditions.append(
"ifnull(tax_category, '') = {}".format(frappe.db.escape(cstr(args.get("tax_category")), False))
)
if "tax_category" in args.keys():
del args["tax_category"]
def get_group_ancestors(doctype, get_parents, value):
if not value:
value = get_root_of(doctype)
return [""] + [d.name for d in get_parents(value)]
group_fields = {
"customer_group": ("Customer Group", get_parent_customer_groups),
"supplier_group": ("Supplier Group", get_parent_supplier_groups),
}
args.setdefault("tax_category", "")
for key, value in args.items():
if key == "use_for_shopping_cart":
conditions.append(f"use_for_shopping_cart = {1 if value else 0}")
elif key == "customer_group":
if not value:
value = get_root_of("Customer Group")
customer_group_condition = get_customer_group_condition(value)
conditions.append(f"ifnull({key}, '') in ('', {customer_group_condition})")
query = query.where(TaxRule.use_for_shopping_cart == value)
elif key == "tax_category":
query = query.where(IfNull(TaxRule.tax_category, "") == (value or ""))
elif key in group_fields:
doctype, get_parents = group_fields[key]
query = query.where(
IfNull(TaxRule[key], "").isin(get_group_ancestors(doctype, get_parents, value))
)
else:
conditions.append(f"ifnull({key}, '') in ('', {frappe.db.escape(cstr(value))})")
query = query.where(IfNull(TaxRule[key], "").isin(["", value or ""]))
tax_rule = frappe.db.sql(
"""select * from `tabTax Rule`
where {}""".format(" and ".join(conditions)),
as_dict=True,
)
tax_rule = query.run(as_dict=True)
if not tax_rule:
return None
@@ -236,11 +243,3 @@ def get_tax_template(posting_date, args):
return None
return tax_template
def get_customer_group_condition(customer_group):
condition = ""
customer_groups = ["%s" % (frappe.db.escape(d.name)) for d in get_parent_customer_groups(customer_group)]
if customer_groups:
condition = ",".join(["%s"] * len(customer_groups)) % (tuple(customer_groups))
return condition

View File

@@ -61,6 +61,117 @@ class TestTaxRule(ERPNextTestSuite):
"_Test Sales Taxes and Charges Template - _TC",
)
def test_for_parent_supplier_group(self):
purchase_template = "_Test Purchase Taxes and Charges Template - _TC"
if not frappe.db.exists("Purchase Taxes and Charges Template", purchase_template):
frappe.get_doc(
{
"doctype": "Purchase Taxes and Charges Template",
"title": "_Test Purchase Taxes and Charges Template",
"company": "_Test Company",
"taxes": [
{
"account_head": "_Test Account VAT - _TC",
"charge_type": "On Net Total",
"description": "VAT",
"doctype": "Purchase Taxes and Charges",
"cost_center": "Main - _TC",
"rate": 6,
}
],
}
).insert()
make_tax_rule(
supplier_group="All Supplier Groups",
tax_type="Purchase",
purchase_tax_template=purchase_template,
priority=1,
use_for_shopping_cart=0,
from_date="2015-01-01",
save=1,
)
# "_Test Supplier Group" has "All Supplier Groups" as its parent — should match hierarchically
self.assertEqual(
get_tax_template(
"2015-01-01",
{
"supplier_group": "_Test Supplier Group",
"tax_type": "Purchase",
"use_for_shopping_cart": 0,
},
),
purchase_template,
)
def test_use_for_shopping_cart_filter(self):
city = "Test Cart City"
# higher priority ensures this rule wins when use_for_shopping_cart is not filtered
make_tax_rule(
customer="_Test Customer",
billing_city=city,
sales_tax_template="_Test Sales Taxes and Charges Template - _TC",
use_for_shopping_cart=0,
priority=2,
save=1,
)
make_tax_rule(
customer="_Test Customer",
billing_city=city,
sales_tax_template="_Test Sales Taxes and Charges Template 1 - _TC",
use_for_shopping_cart=1,
priority=1,
save=1,
)
# Cart request (use_for_shopping_cart=1) filters to cart rules only
self.assertEqual(
get_tax_template(
"2015-01-01",
{"customer": "_Test Customer", "billing_city": city, "use_for_shopping_cart": 1},
),
"_Test Sales Taxes and Charges Template 1 - _TC",
)
# Non-cart request omits use_for_shopping_cart — no filter is applied, both rules
# are candidates; non-cart rule wins by higher priority
self.assertEqual(
get_tax_template(
"2015-01-01",
{"customer": "_Test Customer", "billing_city": city},
),
"_Test Sales Taxes and Charges Template - _TC",
)
def test_use_for_shopping_cart_default(self):
city = "Test Default Cart City"
# use_for_shopping_cart not set — Check field defaults to 0
make_tax_rule(
customer="_Test Customer",
billing_city=city,
sales_tax_template="_Test Sales Taxes and Charges Template - _TC",
use_for_shopping_cart=0, # Default is set to 1.
save=1,
)
# Non-cart request (no use_for_shopping_cart in args) matches the rule
self.assertEqual(
get_tax_template(
"2015-01-01",
{"customer": "_Test Customer", "billing_city": city},
),
"_Test Sales Taxes and Charges Template - _TC",
)
# Cart request (use_for_shopping_cart=1) does not match — rule has default 0
self.assertIsNone(
get_tax_template(
"2015-01-01",
{"customer": "_Test Customer", "billing_city": city, "use_for_shopping_cart": 1},
)
)
def test_conflict_with_overlapping_dates(self):
tax_rule1 = make_tax_rule(
customer="_Test Customer",

View File

@@ -7,7 +7,7 @@ import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.utils import getdate
from frappe.utils import cstr, getdate
from erpnext import allow_regional
from erpnext.controllers.accounts_controller import validate_account_head
@@ -48,7 +48,7 @@ class TaxWithholdingCategory(Document):
for d in self.get("rates"):
if getdate(d.from_date) >= getdate(d.to_date):
frappe.throw(_("Row #{0}: From Date cannot be before To Date").format(d.idx))
group_rates[d.tax_withholding_group].append(d)
group_rates[cstr(d.tax_withholding_group)].append(d)
# Validate overlapping dates within each group
for group, rates in group_rates.items():
@@ -92,10 +92,9 @@ class TaxWithholdingCategory(Document):
def get_applicable_tax_row(self, posting_date, tax_withholding_group):
for row in self.rates:
if (
getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date)
and row.tax_withholding_group == tax_withholding_group
):
if getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date) and cstr(
row.tax_withholding_group
) == cstr(tax_withholding_group):
return row
frappe.throw(_("No Tax Withholding data found for the current posting date."))
@@ -116,7 +115,7 @@ class TaxWithholdingDetails:
def __init__(
self,
tax_withholding_categories: list[str],
tax_withholding_group: str,
tax_withholding_group: str | None,
posting_date: str,
party_type: str,
party: str,

View File

@@ -999,6 +999,47 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
self.cleanup_invoices(invoices)
def test_null_and_empty_tax_withholding_group_are_equivalent(self):
"""
NULL and empty-string `tax_withholding_group` must be treated as the
same value.
"""
category = frappe.get_doc("Tax Withholding Category", "Cumulative Threshold TDS")
original_row = category.rates[0]
original_row.tax_withholding_group = None
# Part 1: validate_dates must detect overlap between NULL-group and
# empty-string-group rows covering the same date range.
category.append(
"rates",
{
"from_date": original_row.from_date,
"to_date": original_row.to_date,
"tax_withholding_group": "",
"tax_withholding_rate": original_row.tax_withholding_rate,
},
)
with self.assertRaises(frappe.ValidationError):
category.validate_dates()
category.rates.pop()
# Part 2: get_applicable_tax_row must match NULL <-> "" in either direction.
posting_date = original_row.from_date
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group="")
self.assertEqual(row.name, original_row.name)
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group=None)
self.assertEqual(row.name, original_row.name)
original_row.tax_withholding_group = ""
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group=None)
self.assertEqual(row.name, original_row.name)
original_row.tax_withholding_group = None
with self.assertRaises(frappe.ValidationError):
category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group="194R")
def test_tds_calculation_on_net_total(self):
self.setup_party_with_category("Supplier", "Test TDS Supplier4", "Cumulative Threshold TDS")
invoices = []

View File

@@ -430,7 +430,7 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False):
gle.flags.adv_adj = adv_adj
gle.flags.update_outstanding = update_outstanding or "Yes"
gle.flags.notify_update = False
if gle.is_cancelled:
if gle.is_cancelled or is_immutable_ledger_enabled():
gle.flags.ignore_links = True
gle.submit()
@@ -718,7 +718,12 @@ def make_reverse_gl_entries(
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
validate_against_pcv(is_opening, gl_entries[0]["posting_date"], gl_entries[0]["company"])
# For reverse entries, use the posting_date parameter if provided and valid
# Otherwise fall back to original posting_date
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
if partial_cancel:
# Partial cancel is only used by `Advance` in separate account feature.
# Only cancel GL entries for unlinked reference using `voucher_detail_no`

View File

@@ -687,7 +687,7 @@ def validate_due_date_with_template(posting_date, due_date, bill_date, template_
if not default_due_date:
return
if default_due_date != posting_date and getdate(due_date) > getdate(default_due_date):
if getdate(default_due_date) != getdate(posting_date) and getdate(due_date) > getdate(default_due_date):
if frappe.get_single_value("Accounts Settings", "credit_controller") in frappe.get_roles():
party_type = "supplier" if doctype == "Purchase Invoice" else "customer"
@@ -762,7 +762,7 @@ def set_taxes(
args.update({"tax_type": "Purchase"})
if use_for_shopping_cart:
args.update({"use_for_shopping_cart": use_for_shopping_cart})
args.update({"use_for_shopping_cart": cint(use_for_shopping_cart)})
return get_tax_template(posting_date, args)

View File

@@ -1,161 +0,0 @@
{%- macro add_header(page_num, max_pages, doc, letter_head, no_letterhead, footer, print_settings=None, print_heading_template=None) -%}
{% if letter_head and not no_letterhead %}
<div class="letter-head">{{ letter_head }}</div>
{% endif %}
{% if print_heading_template %}
{{ frappe.render_template(print_heading_template, {"doc":doc}) }}
{% else %}
{% endif %}
{%- if doc.meta.is_submittable and doc.docstatus==2-%}
<div class="text-center" document-status="cancelled">
<h4 style="margin: 0px;">{{ _("CANCELLED") }}</h4>
</div>
{%- endif -%}
{%- endmacro -%}
{% for page in layout %}
<div class="page-break">
<div {% if print_settings.repeat_header_footer %} id="header-html" class="hidden-pdf" {% endif %}>
{{ add_header(loop.index, layout|len, doc, letter_head, no_letterhead, footer, print_settings) }}
</div>
<style>
.taxes-section .order-taxes.mt-5{
margin-top: 0px !important;
}
.taxes-section .order-taxes .border-btm.pb-5{
padding-bottom: 0px !important;
}
.print-format label{
color: #74808b;
font-size: 12px;
margin-bottom: 4px;
}
</style>
{% if print_settings.repeat_header_footer %}
<div id="footer-html" class="visible-pdf">
{% if not no_letterhead and footer %}
<div class="letter-head-footer">
{{ footer }}
</div>
{% endif %}
<p class="text-center small page-number visible-pdf">
{{ _("Page {0} of {1}").format('<span class="page"></span>', '<span class="topage"></span>') }}
</p>
</div>
{% endif %}
<div class="row section-break" style="margin-bottom: 10px;">
<div class="col-xs-6 p-0">
<div class="col-xs-12 value text-uppercase"><b>{{ doc.customer }}</b></div>
<div class="col-xs-12">
{{ doc.address_display }}
</div>
<div class="col-xs-12">
{{ _("Contact: ")+doc.contact_display if doc.contact_display else '' }}
</div>
<div class="col-xs-12">
{{ _("Mobile: ")+doc.contact_mobile if doc.contact_mobile else '' }}
</div>
</div>
<div class="col-xs-3"></div>
<div class="col-xs-3" style="padding-left: 5px;">
<div>
<div><label>{{ _("Invoice ID") }}</label></div>
<div>{{ doc.name }}</div>
</div>
<div style="margin-top: 20px;">
<div><label>{{ _("Invoice Date") }}</label></div>
<div>{{ frappe.utils.format_date(doc.posting_date) }}</div>
</div>
<div style="margin-top: 20px;">
<div><label>{{ _("Due Date") }}</label></div>
<div>{{ frappe.utils.format_date(doc.due_date) }}</div>
</div>
</div>
</div>
<div class="section-break">
<table class="table table-bordered table-condensed mb-0" style="width: 100%; border-collapse: collapse; font-size: 12px;">
<colgroup>
<col style="width: 5%">
<col style="width: 45%">
<col style="width: 10%">
<col style="width: 20%">
<col style="width: 20%">
</colgroup>
<thead>
<tr>
<th class="text-uppercase" style="text-align:center">{{ _("Sr") }}</th>
<th class="text-uppercase" style="text-align:center">{{ _("Details") }}</th>
<th class="text-uppercase" style="text-align:center">{{ _("Qty") }}</th>
<th class="text-uppercase" style="text-align:right">{{ _("Rate") }}</th>
<th class="text-uppercase" style="text-align:right">{{ _("Amount") }}</th>
</tr>
</thead>
{% for item in doc.items %}
<tr>
<td style="text-align:center">{{ loop.index }}</td>
<td>
<b>{{ item.item_code }}: {{ item.item_name }}</b>
{% if (item.description != item.item_name) %}
<br>{{ item.description }}
{% endif %}
</td>
<td style="text-align: center;">
{{ item.get_formatted("qty", 0) }}
{{ item.get_formatted("uom", 0) }}
</td>
<td style="text-align: right;">{{ item.get_formatted("net_rate", doc) }}</td>
<td style="text-align: right;">{{ item.get_formatted("net_amount", doc) }}</td>
</tr>
{% endfor %}
</table>
<!-- total -->
<div class="row">
<div class="col-xs-6">
<div>
<label>{{ _("Amount in Words") }}</label>
{{ doc.in_words }}
</div>
<div style="margin-top: 20px;">
<label>{{ _("Payment Status") }}</label>
{{ doc.status }}
</div>
</div>
<div class="col-xs-6">
<div class="row section-break">
<div class="col-xs-7"><div>{{ _("Sub Total") }}</div></div>
<div class="col-xs-5" style="text-align: right;">{{ doc.get_formatted("net_total", doc) }}</div>
</div>
<div>
{% for d in doc.taxes %}
{% if d.tax_amount %}
<div class="row">
<div class="col-xs-8"><div>{{ _(d.description) }}</div></div>
<div class="col-xs-4" style="text-align: right;">{{ d.get_formatted("tax_amount") }}</div>
</div>
{% endif %}
{% endfor %}
</div>
<div class="row">
<div class="col-xs-7"><div>{{ _("Total") }}</div></div>
<div class="col-xs-5" style="text-align: right;">{{ doc.get_formatted("grand_total", doc) }}</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="row important data-field">
<div class="col-xs-12"><label>{{ _("Terms and Conditions") }}: </label></div>
<div class="col-xs-12">{{ doc.terms if doc.terms else '' }}</div>
</div>
</div>
</div>
</div>
{% endfor %}

View File

@@ -1,32 +0,0 @@
{
"absolute_value": 0,
"align_labels_right": 0,
"creation": "2025-01-22 16:23:51.012200",
"css": "",
"custom_format": 0,
"default_print_language": "en",
"disabled": 0,
"doc_type": "Sales Invoice",
"docstatus": 0,
"doctype": "Print Format",
"font": "",
"font_size": 14,
"idx": 0,
"line_breaks": 0,
"margin_bottom": 0.0,
"margin_left": 0.0,
"margin_right": 0.0,
"margin_top": 0.0,
"modified": "2025-01-22 16:23:51.012200",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Print",
"owner": "Administrator",
"page_number": "Hide",
"print_format_builder": 0,
"print_format_builder_beta": 0,
"print_format_type": "Jinja",
"raw_printing": 0,
"show_section_headings": 0,
"standard": "Yes"
}

Some files were not shown because too many files have changed in this diff Show More