Compare commits

...

155 Commits

Author SHA1 Message Date
Mohd Haris
a3f262b415 fix: restrict Party Type/Party to Receivable/Payable accounts in Journal Entry
Journal Entry validation only checked party details when an account was
of type Receivable/Payable, but never restricted setting a Party Type or
Party against accounts of other types. This regressed v14 behavior where
party info could only be captured for Receivable/Payable accounts.

Add a branch to validate_party() that throws when a Party Type or Party
is set on an account that is not Receivable/Payable, with a clear message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:21:51 +05:30
ruthra kumar
7e602d5389 Merge pull request #53152 from aerele/fix_payment_entry
fix: prevent exchange rate flow from transaction to payment
2026-06-16 14:02:55 +05:30
rohitwaghchaure
529f8dc7cd Merge pull request #55826 from rohitwaghchaure/moved-files-to-services
refactor: moved files from stock_entry_handler to services
2026-06-16 13:57:08 +05:30
Shllokkk
52b406f5f1 fix(budget): add root_type filter on account field (#55934) 2026-06-16 11:43:52 +05:30
MochaMind
3dda2005d8 fix: sync translations from crowdin (#55900) 2026-06-16 11:13:36 +05:30
Jatin3128
322d4dff25 fix: clear stale payment rows on non-POS returns so they don't surface in bank reconciliation (#55903) 2026-06-16 10:42:36 +05:30
ruthra kumar
01a10fb5b0 Merge pull request #55949 from ruthra-kumar/speed_up_item_wise_inventory_account_tests
refactor(test): speed up item wise inventory test
2026-06-16 08:47:00 +05:30
ruthra kumar
4c084f7eff Merge pull request #55948 from ruthra-kumar/transaction_deletion_record_test_speed_up
refactor(test): faster transaction deletion record tests
2026-06-16 08:34:03 +05:30
ruthra kumar
627f2058b5 refactor(test): speed up item wise inventory test 2026-06-16 08:23:45 +05:30
ruthra kumar
8db4d2705a refactor(test): dont create master data in setUp 2026-06-16 08:03:47 +05:30
ruthra kumar
4ca7bc8ccf Merge pull request #55942 from ruthra-kumar/speed_up_delivery_note_tests
refactor(test): dont create company in setUp of Deliv Note
2026-06-15 20:16:16 +05:30
rohitwaghchaure
ca5cc4afdc Merge pull request #55928 from aerele/fix/support-#70854
fix(stock): update stock value calculation in stock balance report
2026-06-15 18:10:23 +05:30
Jatin3128
380b005659 fix: fiscal year check on validation (#55930) 2026-06-15 18:08:05 +05:30
ruthra kumar
df0ad93262 refactor(test): dont create company in setUp of Deliv Note 2026-06-15 17:51:02 +05:30
ruthra kumar
f503614cc0 Merge pull request #55929 from ruthra-kumar/parallelize_and_optimize_install_helper
ci: optimize install helper setup
2026-06-15 17:39:24 +05:30
Rohit Waghchaure
6d9beea56b refactor: moved files from stock_entry_handler to services 2026-06-15 17:28:50 +05:30
rohitwaghchaure
560d8bb674 Merge pull request #55926 from rohitwaghchaure/fixed-recalculate-rate-for-purchase-doc
fix: recalculate incoming rate in SLE for purchase documents during repost
2026-06-15 17:03:56 +05:30
Mihir Kandoi
a3e3e1b32c ci: optimize install helper setup
Cc: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:50:50 +05:30
Sudharsanan11
2492dfa558 fix(stock): update stock value calculation in stock balance report 2026-06-15 16:27:18 +05:30
ervishnucs
3b5a203d61 test: resolve failed testcases for exchage rate 2026-06-15 16:20:04 +05:30
ervishnucs
934abe5c6d fix: prevent exchange rate flow from transaction to payment 2026-06-15 16:20:04 +05:30
Rohit Waghchaure
867ee484b9 fix: recalculate incoming rate in SLE for purchase documents during repost 2026-06-15 16:09:03 +05:30
Diptanil Saha
2652082475 Merge pull request #55755 from diptanilsaha/feat/ces_frankfurter_v2
feat(currency exchange settings): frankfurter v2 support
2026-06-15 14:22:49 +05:30
diptanilsaha
abb579e2db fix(get_exchange_rate): using get_single_value to fetch disabled value from currency_exchange_settings 2026-06-15 13:52:34 +05:30
diptanilsaha
0c2d5488a6 fix: restricting currency_exchange_settings write permission only to system manager 2026-06-15 13:52:34 +05:30
diptanilsaha
138f683a68 test: fixed currency exchange test for frankfurter v2 api 2026-06-15 13:52:27 +05:30
diptanilsaha
479f9f63c9 fix: use frankfurter v2 by default for new install 2026-06-15 13:52:06 +05:30
diptanilsaha
56bfe6b6a6 feat(currency exchange settings): frankfurter v2 support 2026-06-15 13:52:06 +05:30
rohitwaghchaure
acae34c8e1 Merge pull request #55901 from rohitwaghchaure/fixed-regression-security-fixes
fix: regression issues related to security fixes
2026-06-15 12:20:38 +05:30
Mihir Kandoi
dcbe4a6d55 Merge pull request #55906 from raghavisruia/develop
fix: show company name in delete transactions confirmation dialog
2026-06-15 09:49:59 +05:30
Raghav Ruia
87d26a2d67 fix: show company name in delete transactions confirmation dialog
Display the actual company name in bold within the confirmation dialog
label so users immediately know which company they must type to confirm,
reducing the risk of accidental data loss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 09:45:55 +05:30
Rohit Waghchaure
e1d8d06966 refactor: consolidate duplicate get_party_bank_account into bank_account.py 2026-06-14 23:53:35 +05:30
Rohit Waghchaure
8c88cecc1f fix: regression issues related to security fixes 2026-06-14 23:42:48 +05:30
MochaMind
9aeafb8140 fix: sync translations from crowdin (#55784) 2026-06-14 17:37:37 +00:00
MochaMind
c24e9796ae chore: update POT file (#55894) 2026-06-14 13:11:21 +02:00
rohitwaghchaure
c7d42e161b Merge pull request #55877 from rohitwaghchaure/feat-allow_to_edit_stock_uom_qty_for_stock_entry
feat: Allow to edit stock UOM qty for Stock Entry
2026-06-14 09:54:24 +05:30
Raffael Meyer
701896692a ci: set disabledLabels and context for greptile (#55883) 2026-06-13 18:46:03 +00:00
Raffael Meyer
93d6be2ed7 fix(Lead): stop storing Gravatar image URLs for Leads (#55880) 2026-06-13 19:03:29 +02:00
Rohit Waghchaure
b0e9ad198f feat: Allow to edit stock UOM qty for Stock Entry 2026-06-13 21:41:05 +05:30
Dipen Gala
a9029f83c7 feat(invoices): add tooltip description to Update Stock checkbox (#55868)
* feat(invoices): add tooltip description to Update Stock checkbox

Adds a description below the Update Stock checkbox on both Sales Invoice
and Purchase Invoice so users understand when to use the field without
consulting documentation.

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

* feat(invoices): replace Update Stock description with hover info tooltip

Removes the inline description text and adds an ℹ icon next to the
Update Stock checkbox label on both Sales Invoice and Purchase Invoice.
Hovering the icon shows the contextual tooltip via Bootstrap tooltip.

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

* fix(invoices): use Frappe native tooltip-content class for Update Stock icon

Replace Bootstrap .tooltip() (pure black bg) with Frappe's own
.tooltip-content CSS class so the hover tooltip matches the rest of
the ERPNext UI — uses var(--bg-dark-gray) and var(--text-dark).

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

* fix(invoices): use frappe.ui.SidebarCard for Update Stock info tooltip

Replace custom CSS tooltip with the same SidebarCard + Popper approach
Frappe's InfoCard uses for field description tooltips — gives the native
ERPNext card appearance (white card, border, shadow) on hover.

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

* refactor(invoices): use built-in field description for Update Stock tooltip

Replace custom SidebarCard JS tooltip with Frappe's native
description + show_description_on_click field property on the
update_stock field in Sales Invoice and Purchase Invoice.

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

* fix: remove duplicate description in purchase_invoice update_stock field

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

* revert: restore custom tooltip in purchase_invoice.js

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

* revert: remove all changes from purchase_invoice.js

Keep purchase_invoice.js identical to upstream develop.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:48:03 +05:30
rohitwaghchaure
31e4da562d Merge pull request #55874 from rohitwaghchaure/fixed-permission-for-bom-comparison-tool
fix: permission in bom compare tool
2026-06-13 19:10:38 +05:30
Rohit Waghchaure
e6fdb3702a fix: permission in bom compare tool 2026-06-13 19:09:04 +05:30
rohitwaghchaure
bd60a9be90 Merge pull request #55849 from rohitwaghchaure/fixed-permissions-for-whitelist-functions
fix: permission for whitelist functions
2026-06-13 18:36:46 +05:30
Rohit Waghchaure
a64466561f fix: permission for whitelist functions 2026-06-13 17:45:37 +05:30
Mihir Kandoi
f7ff25d9a8 Merge pull request #55835 from mihir-kandoi/codex/develop-user-disable-audit-fix
fix: sync employee user status after save
2026-06-13 14:49:34 +05:30
Diptanil Saha
c933e34914 fix: opportunity creation from contact us page (#55841) 2026-06-13 04:47:45 +00:00
Mihir Kandoi
87092961e7 Merge pull request #55853 from SandraFrappe/fix/cost-center
fix: pass source cost center to target cost center
2026-06-12 20:57:04 +05:30
rohitwaghchaure
3f436985ed Merge pull request #55844 from rohitwaghchaure/fixed-job-card-permissions
fix: permissions in workstation file
2026-06-12 16:05:24 +05:30
Rohit Waghchaure
cf127e8900 fix: permissions in workstation file 2026-06-12 15:37:46 +05:30
SandraFrappe
9ea766fc10 fix: pass source cost center to target cost center 2026-06-12 14:44:22 +05:30
rohitwaghchaure
53180fde93 Merge pull request #55845 from frappe/fix-update-stock-expense-head-warning
fix: remove unnecessary expense head warning for purchase invoices with update stock
2026-06-12 13:29:34 +05:30
Dipen Gala
224dff32df fix: remove unnecessary expense head warning for purchase invoices with update stock
When a Purchase Invoice is created with `update_stock = 1`, the system
automatically replaces the item's expense account with the correct
inventory account for perpetual inventory. This is expected behaviour,
but a `frappe.msgprint` warning was being shown to the user:

  "Expense Head changed to Stock In Hand because account Cost of Goods
   Sold is not linked to warehouse Stores or it is not the default
   inventory account."

The message is purely informational, provides no actionable guidance,
and confuses users who deliberately enable Update Stock. The underlying
account substitution logic is unchanged; only the popup is suppressed.

The two other `msgprint` calls (for the Purchase-Receipt-linked and
no-Purchase-Receipt flows) are intentionally preserved — those surface
a genuine change in behaviour that users may not expect.

Fixes: https://github.com/frappe/erpnext/issues/...
2026-06-12 12:57:58 +05:30
rohitwaghchaure
292bfa2a34 Merge pull request #55832 from rohitwaghchaure/fixed-regression-55827
fix: bom creator issue
2026-06-12 00:00:01 +05:30
Mihir Kandoi
e90896ced7 Merge pull request #55838 from aerele/fix/uom-mandatory
fix(stock): make uom mandatory in item uom table
2026-06-11 23:39:00 +05:30
Rohit Waghchaure
c360487cd1 fix: converted whitelist non class methods to class methods 2026-06-11 23:33:47 +05:30
pandiyan
a0177fdbe8 fix(stock): make uom mandatory in item uom table 2026-06-11 23:03:27 +05:30
Mihir Kandoi
64175bdb3e fix: skip unchanged employee user status sync 2026-06-11 21:34:43 +05:30
Mihir Kandoi
4fed04c6c7 fix: sync employee user status after save 2026-06-11 20:58:35 +05:30
Rohit Waghchaure
35fe9c60c7 fix: bom creator issue 2026-06-11 20:27:35 +05:30
Mihir Kandoi
878c22fa3f Merge pull request #55820 from aerele/fix/support-70979
fix: show user disable audit log
2026-06-11 20:27:01 +05:30
rohitwaghchaure
12ada21639 Merge pull request #55827 from rohitwaghchaure/fixed-bom-creator-security-issues
fix: multiple issues related to BOM Creator
2026-06-11 19:45:12 +05:30
Rohit Waghchaure
daf3f2e142 fix: multiple issues related to BOM Creator 2026-06-11 19:15:14 +05:30
rohitwaghchaure
ea3ec325e2 Merge pull request #55806 from rohitwaghchaure/refactor-stock-reservation
refactor: stock reservation feature
2026-06-11 13:53:23 +05:30
pandiyan
73d1852773 fix: show user disable audit log 2026-06-11 13:48:41 +05:30
Rohit Waghchaure
9c5f9218b5 refactor: stock reservation feature 2026-06-11 13:27:13 +05:30
ruthra kumar
a8a78a2163 Merge pull request #55695 from kaulith/fix/ar-report-respect-user-permissions
fix: apply user permissions to receivable/payable reports
2026-06-11 12:27:27 +05:30
Diptanil Saha
0b6121422d fix: added doctype filter validation for sales person wise transaction summary report (#55812) 2026-06-11 06:50:21 +00:00
Mohammad Umair Sayed
9249fa89aa fix(bom): fetch routing operations when Routing is selected (#55813)
fix(bom): fetch routing operations when routing is selected

frm.doc.operations is always an array in Frappe, so !frm.doc.operations
was always false (empty array [] is truthy in JS), causing get_routing()
to never fire when a Routing is selected on a BOM with no existing
operations.

Changed the guard to !frm.doc.operations.length so the fetch triggers
correctly when the operations table is empty.

Also wired the same fetch into the with_operations handler so that
enabling the checkbox after a Routing is already set will populate
operations without requiring the user to re-select the Routing.

Co-authored-by: Umair Sayed <umairsayed@Umairs-MacBook-Air-2.local>
2026-06-11 06:20:00 +00:00
Mihir Kandoi
5a816d19cb Merge pull request #55793 from mihir-kandoi/fix-bundle-dialog-lookup
fix(buying): resolve Get Items from Product Bundle by document name
2026-06-10 22:31:14 +05:30
Mihir Kandoi
a7d41f24a3 fix(stock): don't KeyError when neither bundle nor item_code is passed
row is a plain dict subclass, so row["item_code"] raised an unhandled
KeyError (500) when the payload had neither key. get_active_product_bundle
already returns None for falsy input, yielding an empty item list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:02:37 +05:30
Mihir Kandoi
81a1c2c8ce fix(stock): document-level permission check on the legacy bundle path
The legacy item_code path now resolves the active bundle's name via
get_active_product_bundle (same filters as the old joined query) so
frappe.has_permission can validate the specific document on both
branches. The orphaned get_product_bundle_items helper is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:51:49 +05:30
Mihir Kandoi
0c6f7fed55 fix(stock): permission check and test cleanup for bundle item fetch
The whitelisted get_items_from_product_bundle endpoint now verifies read
permission on Product Bundle (doc-level when a name is passed, doctype-
level for the legacy item_code path) so authenticated users can't
enumerate bundle components. The disabled-bundle test also restores the
disabled flag via addCleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:42:55 +05:30
Mihir Kandoi
bfee9df9aa fix: linter error 2026-06-10 18:53:32 +05:30
Mihir Kandoi
bddd1d0ebc fix(buying): resolve Get Items from Product Bundle by document name
Since Product Bundles became versioned, their names are PB-prefixed and
no longer double as the parent item code. The buying dialog kept passing
the picked bundle name as `item_code`, so the component lookup (which
filters `new_item_code`) matched nothing and the dialog silently added
no items.

The dialog now sends the selection as `product_bundle` and the endpoint
fetches that version's components by document name (rejecting
unsubmitted versions); passing `item_code` still resolves the parent
item's active version, preserving the legacy contract of the
whitelisted endpoint. The picker is also restricted to submitted
bundles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:40:12 +05:30
Nabin Hait
aa9f225c41 Merge pull request #55780 from nabinhait/refactor-je-services-internals
refactor(journal_entry): tidy the JE services and mapper internals
2026-06-10 11:54:04 +05:30
Mihir Kandoi
9c799f31ff Merge pull request #55791 from mihir-kandoi/product-bundle-disabled
feat(selling): allow disabling a Product Bundle
2026-06-10 11:42:25 +05:30
Mihir Kandoi
a60afaf91a Merge pull request #55789 from mihir-kandoi/fix-stock-ageing-unbuffered-cursor
fix: prefetch batchwise valuations before streaming SLEs in stock ageing
2026-06-10 11:37:23 +05:30
Nabin Hait
a4cff805f1 test(journal_entry): pin transaction-currency conversion in GL entries
Mutation testing on gl_composer surfaced that the foreign-row
debit/credit_in_transaction_currency conversion (amount / exchange_rate) was
unverified -- a / vs * bug survived. Assert those fields in test_multi_currency
and add a foreign-debit case so both conversion directions are now caught.
2026-06-10 11:23:19 +05:30
Nabin Hait
4f55071eda test(journal_entry): cover the untested mapper builders
Add characterization tests for get_payment_entry_against_order (the Sales/
Purchase Order advance path, previously untested) and make_inter_company_journal_entry
(previously fully uncovered).
2026-06-10 11:23:19 +05:30
Nabin Hait
43bb6c5a42 refactor(journal_entry): break up unlink_asset_reference and type/document asset service
Split AssetService.unlink_asset_reference into _is_depreciation_asset_row /
_reverse_asset_depreciation / _restore_scheduled_depreciation /
_restore_finance_book_value / _block_scrap_journal_cancel, and add return type
hints and docstrings across the service. Behaviour preserved (netted by the
asset suite).
2026-06-10 11:22:15 +05:30
Nabin Hait
34955380ee refactor(journal_entry): break up get_payment_entry and add types/docstrings to mapper
Split get_payment_entry into _reference_exchange_rate / _append_party_row /
_append_bank_row, and add return type hints and docstrings to all mapper
document builders. Behaviour preserved.
2026-06-10 11:22:15 +05:30
Nabin Hait
1714e13b39 refactor(journal_entry): tidy reference-validator and GL-composer services
Add return type hints and option-A docstrings to JournalEntryReferenceValidator,
and split JournalEntryGLComposer.compose into _set_transaction_currency and
_gl_row helpers. Behaviour preserved.
2026-06-10 11:22:15 +05:30
Nabin Hait
263c3e9dd4 Merge pull request #55779 from nabinhait/refactor-je-functions
refactor(journal_entry): smaller functions, Query Builder, type hints and docstrings
2026-06-10 11:20:05 +05:30
Mihir Kandoi
c97c2d1e02 test(selling): cover disabled Product Bundle behaviour
Resolution skips disabled bundles, transactions referencing a disabled
version are blocked, rows without an explicit version stop packing, and
the Item Where Used report surfaces the disabled flag on bundle rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:57:48 +05:30
Mihir Kandoi
cf37478870 feat(selling): allow disabling a Product Bundle
Un-deprecate the `disabled` checkbox: it is now editable (also after
submit) and parks a bundle version without ceding its active slot, so
re-enabling restores it without re-activation.

- `get_active_product_bundle` (the single resolution entry point) skips
  disabled bundles, so every consumer stops treating the item as a bundle
  while it is disabled
- the version pickers on transaction item rows and the buying "Get Items
  from Product Bundle" dialog filter out disabled bundles
- an explicitly selected disabled version blocks the transaction with a
  validation error instead of silently re-packing another version
- Product Bundle Balance report excludes disabled bundles
- list view indicator: Disabled (grey) / Active (green), falling back to
  docstatus for drafts, cancelled and inactive submitted versions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:57:48 +05:30
Mihir Kandoi
060a5c4eeb fix: prefetch batchwise valuations before streaming SLEs in stock ageing
Stock Ageing iterates stock ledger entries through an unbuffered
(streaming) cursor. _get_batchwise_valuation() lazily queried
Batch.use_batchwise_valuation from inside that loop whenever a row
carried the legacy batch_no field, and the nested query invalidated
the active streaming result set — crashing the report (or silently
dropping the remaining rows, depending on the driver version).

Resolve the valuation flags in a single query before entering the
unbuffered cursor block; the lazy lookup now only serves callers that
pass stock ledger entries in directly, where no streaming is active.

Fixes https://github.com/frappe/erpnext/issues/55786

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:49:27 +05:30
Nabin Hait
3ad32f4030 Merge pull request #55274 from yash14023/fix/debit-note-prevent-update-stock
fix(accounts): prevent update_stock on Debit Notes
2026-06-10 10:44:20 +05:30
Diptanil Saha
dfc824ded6 fix(process statement of accounts): validate pdf_name and validate permission before triggering send_auto_email (#55781) 2026-06-09 18:52:28 +00:00
Nabin Hait
f099dbad35 refactor(journal_entry): give get_outstanding an explicit parameter list
Replace the single opaque `args` parameter of the whitelisted get_outstanding
with explicit named parameters (the supported interface), splitting the body
into _get_journal_entry_outstanding / _get_invoice_outstanding. The legacy
`args` payload is still accepted via kwargs for backward compatibility with
custom apps. Resolves the overusing-args semgrep finding.
2026-06-09 23:28:12 +05:30
Nabin Hait
cc8ce03232 test(journal_entry): cover write-off, balance and advance-unlink flows; drop dead code
Add characterization tests for the previously untested get_balance (difference
on a blank row), get_outstanding_invoices (write-off rows) and
unlink_advance_entry_reference (reference cleared on cancel). Remove the unused
get_average_exchange_rate, which has no callers in erpnext.
2026-06-09 23:07:03 +05:30
Nabin Hait
bcc1e73962 docs(journal_entry): add class and public-method docstrings
Add a class docstring plus docstrings for the lifecycle hooks and the public
API helpers (get_outstanding, get_against_jv, get_exchange_rate, etc.).
Self-evident one-line methods are intentionally left undocumented.
2026-06-09 22:44:19 +05:30
Nabin Hait
32d7250946 refactor(journal_entry): break up reporting, exchange-rate and balance methods
Decompose update_invoice_discounting, set_print_format_fields,
get_balance_for_periodic_accounting, set_exchange_rate, get_balance and
get_outstanding_invoices into focused per-row / row-building helpers (verb
prefixed, with docstrings). The nested closure in update_invoice_discounting
that ignored its row id is dropped. Behaviour preserved.
2026-06-09 22:40:35 +05:30
Nabin Hait
4c1cabb53e refactor(journal_entry): break up create_remarks and validate_against_jv
Split create_remarks into _cheque_remark / _reference_remark / _bill_remark
helpers, and validate_against_jv into _validate_jv_reference,
_validate_jv_reference_direction and _against_jv_entries. Add docstrings.
Behaviour preserved.
2026-06-09 22:30:51 +05:30
Nabin Hait
1105cb8ddf refactor(journal_entry): add missing type hints
Add return annotations to the module-level helpers and to make_gl_entries,
get_balance and set_total_amount, plus parameter types for set_total_amount
and make_gl_entries.
2026-06-09 22:24:44 +05:30
Nabin Hait
8bb4ffc6b1 refactor(journal_entry): replace raw SQL with Query Builder
Convert the five raw frappe.db.sql calls to Query Builder / ORM: the
against-JV lookup, the write-off invoice listing (get_values, now a single
query), the JV outstanding aggregate (get_outstanding), and the bill-no
lookup (get_value). Behaviour preserved.
2026-06-09 22:17:49 +05:30
Nabin Hait
dfd7cd0bae Merge pull request #55767 from nabinhait/refactor-je-extract-services
refactor(journal_entry): extract reference, asset and document-builder services
2026-06-09 22:08:55 +05:30
rohitwaghchaure
e083aa4c86 Merge pull request #55778 from rohitwaghchaure/fixed-github-55621-develop
fix: Stock Reservation blocks Subcontracting operation within the same work order
2026-06-09 21:06:10 +05:30
Rohit Waghchaure
c4fbc745db fix: Stock Reservation blocks Subcontracting operation within the same Work Order 2026-06-09 20:40:00 +05:30
Mihir Kandoi
2b6234f7af fix: handle multi-select stock ageing filters (#55774) 2026-06-09 13:58:17 +00:00
MochaMind
88b9911136 fix: sync translations from crowdin (#55638) 2026-06-09 18:05:38 +05:30
Lakshit Jain
360f52e636 fix(taxes): add category and add_deduct_tax fields to tax entries (#55753) 2026-06-09 18:02:33 +05:30
Mihir Kandoi
6201fefdfb fix: show inactive product bundles in item where used (#55769) 2026-06-09 12:27:54 +00:00
Lakshit Jain
08129ff71c fix: update round off account functions to accept document context for regional overrides (#55758) 2026-06-09 17:52:19 +05:30
rohitwaghchaure
5357634b70 Merge pull request #55765 from rohitwaghchaure/fixed-github-55621
fix: Stock Reservation blocks Subcontracting operation within the same work order
2026-06-09 17:43:47 +05:30
Rohit Waghchaure
20ba97aa7d fix: Stock Reservation blocks Subcontracting operation within the same Work Order 2026-06-09 17:15:56 +05:30
Nabin Hait
d90d4c29e1 refactor(journal_entry): move mapper re-export to the top import block 2026-06-09 16:59:27 +05:30
Nabin Hait
ddbd61b2a2 refactor(journal_entry): point erpnext imports at mapper, trim re-exports
Update erpnext's own importers (asset depreciation, invoice discounting and the
JE tests) to import the builders from mapper.py directly. Drop
make_inter_company_journal_entry and make_reverse_journal_entry from the
backward-compat re-export in journal_entry.py -- they are not part of the
custom-app call surface; only the payment-entry builders remain re-exported.
2026-06-09 16:59:27 +05:30
Nabin Hait
6a7c9f616e refactor(journal_entry): extract document builders into mapper.py
Move the Payment Entry / Journal Entry builders (get_payment_entry and its
against-order/against-invoice helpers, make_inter_company_journal_entry,
make_reverse_journal_entry) into mapper.py. The whitelisted builders are
re-exported from journal_entry.py so existing call paths -- including custom
apps -- keep working, and the erpnext client calls now point at the mapper
path. get_payment_entry imports the exchange-rate/bank-account helpers lazily
to avoid a circular import with the re-export.
2026-06-09 16:59:27 +05:30
Nabin Hait
a3194720b4 refactor(journal_entry): rename asset service to AssetService
Rename JournalEntryAssetLinkage -> AssetService and the file asset_linkage.py
-> asset_service.py.
2026-06-09 16:59:27 +05:30
Nabin Hait
7825ddf989 refactor(journal_entry): extract asset linkage into a service
Move the nine asset/depreciation coupling methods (depreciation-account
validation, asset value updates on depreciation and disposal, and the
unlink-on-cancel logic) out of the controller into a JournalEntryAssetLinkage
service under services/. Pure behaviour-preserving move, netted by the asset
suite (asset, asset_value_adjustment) plus the JE module.
2026-06-09 16:59:27 +05:30
Nabin Hait
e9b67ff682 refactor(journal_entry): extract reference validation into a service
Move validate_reference_doc and its helpers, plus validate_orders and
validate_invoices, out of the controller into a JournalEntryReferenceValidator
service under services/. Behaviour preserved; the per-reference totals stay on
the document. The order/invoice validators are split into <=15-line helpers.
2026-06-09 16:59:27 +05:30
Jatin3128
4c3aa9b4f3 feat(subscription): add refunded status, billing heatmap and billing UX (#55617)
* fix(subscription): bill on creation and keep status in sync with invoices

* feat(subscription): add refunded status, billing heatmap and billing UX
2026-06-09 16:43:24 +05:30
Nabin Hait
ca77145522 Merge pull request #55749 from nabinhait/refactor-je-validate-reference-doc
refactor(journal_entry): split validate_reference_doc into per-row methods
2026-06-09 16:13:45 +05:30
Nabin Hait
5753c23ccf refactor(journal_entry): clarify reference helper names
Rename three private helpers for intent and to drop an abbreviation:
_is_validatable_reference -> _has_party_reference,
_accumulate_reference -> _register_reference,
_reference_dr_or_cr -> _reference_amount_field.
2026-06-09 15:28:12 +05:30
rohitwaghchaure
a397e82278 Merge pull request #55760 from rohitwaghchaure/fixed-github-55756
fix: don't allow to submit job card with hold status
2026-06-09 14:29:53 +05:30
Rohit Waghchaure
9c23229cbf fix: don't allow to submit job card with hold status 2026-06-09 14:03:27 +05:30
Mihir Kandoi
08f6af867a feat: record and select Product Bundle version on transactions (#55738)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:15:20 +05:30
rohitwaghchaure
6988781f81 Merge pull request #55748 from rohitwaghchaure/fixed-suppoort-70455
fix: sql injection
2026-06-09 09:25:24 +05:30
Nabin Hait
49093b326e refactor(journal_entry): split validate_reference_doc into per-row methods
Extract the 100-line, CC-27 validate_reference_doc into a thin orchestrator
loop plus focused per-row private methods, and lift the inline reference
field map to a module constant. Behaviour preserved; complexity drops from
27 to 3 and no extracted function exceeds 15 lines.
2026-06-08 23:18:52 +05:30
Nabin Hait
9503dd0c7f test(journal_entry): characterize validate_reference_doc branches
Pin every branch of validate_reference_doc before refactoring: Sales Order
debit / Purchase Order credit rejection, non-existent reference handling,
Sales/Purchase Invoice and Order party mismatches, and population of the
reference_totals/types/accounts side effects.
2026-06-08 23:18:22 +05:30
Rohit Waghchaure
bd0acf4413 fix: sql injection 2026-06-08 23:10:33 +05:30
rohitwaghchaure
969cdf1b26 Merge pull request #55737 from rohitwaghchaure/fixed-security-issue-job-card
fix: allow specific methods to run
2026-06-08 19:46:59 +05:30
Rohit Waghchaure
8db1eb0d27 fix: allow specific methods to run 2026-06-08 16:06:16 +05:30
rohitwaghchaure
d146dc5435 Merge pull request #55724 from rohitwaghchaure/fixed-support-67770-3
fix: validate fg and materials qty in the disassemble entry
2026-06-08 16:04:29 +05:30
rohitwaghchaure
0ca38517f3 Merge pull request #55716 from rohitwaghchaure/fixed-support-67770-2
fix: do not allow to make changes in SABB after submit
2026-06-08 15:25:26 +05:30
ruthra kumar
5d1af7fc93 Merge pull request #55487 from Shllokkk/accounts-perm-fix
fix: add validations in accounts whitelisted methods
2026-06-08 15:15:37 +05:30
Ankush Menat
1fab935434 fix: only require read for hold
Support weird workflows.
2026-06-08 15:12:24 +05:30
ruthra kumar
d6ba0f0eca Merge pull request #55486 from Shllokkk/crm-create-customer-fix
Validations in CRM-api endpoints
2026-06-08 15:10:26 +05:30
Rohit Waghchaure
49164f41b1 fix: validate fg and materials qty in the disassemble entry 2026-06-08 15:06:43 +05:30
Rohit Waghchaure
e36426e235 fix: do not allow to make changes in SABB after submit 2026-06-08 14:59:07 +05:30
Ankush Menat
ba936eefab fix: Add authorization checks on internal functions (#55709) 2026-06-08 14:49:32 +05:30
Mihir Kandoi
5eb9461cfd fix: remove item name from update items dialog item code column (#55718)
Co-authored-by: Abdullah <frappe@LAPTOP-4E788RM4.localdomain>
2026-06-08 13:54:42 +05:30
Nabin Hait
e1e588e416 Merge pull request #55627 from Shllokkk/inact-cust-report
fix(inactive_customers): add allowlist for doctype filter and migrate…
2026-06-08 13:20:09 +05:30
Mihir Kandoi
00880eb657 fix: disallow BOM finished good item in secondary items table (#55710)
The FG item produced by a BOM should not also appear as a secondary
item (Co-Product/By-Product/Scrap/Additional Finished Good). When an
Additional Finished Good shared the main FG's item code, the resulting
Stock Entry ended up with two rows of the same item carrying different
valuation rates. Validate against it instead, exempting legacy rows so
migrated BOMs can still be re-saved.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 07:47:32 +00:00
Mihir Kandoi
ae6aef91bd feat: add item where used report (#55660) 2026-06-08 07:42:37 +00:00
Diptanil Saha
faf92b1368 fix(cheque_print_template): print format creation from cheque print template requires system manager (#55708) 2026-06-08 07:23:26 +00:00
Mihir Kandoi
a52c8fdaea feat: make Product Bundle submittable and versioned (#55702)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:19:42 +05:30
rohitwaghchaure
030e1a77e6 Merge pull request #55645 from aerele/fix/support-70407
fix: bypass project permission check when updating consumed material …
2026-06-08 12:06:20 +05:30
Pandiyan P
d2306b1b29 fix: restrict already invoiced qty in intercompany purchase invoice (#55639) 2026-06-08 11:59:11 +05:30
Nabin Hait
601f39dda7 test(inactive_customers): remove non-positive days test case 2026-06-08 11:55:32 +05:30
kaulith
047e4faa90 fix: update items respect workflow "Only Allow Edit For" role (#55662) 2026-06-08 11:53:12 +05:30
Nabin Hait
8d7edafc99 refactor(inactive_customers): rename sales alias to sales_doctype 2026-06-08 11:52:56 +05:30
Nabin Hait
8f15dd4d5d refactor(inactive_customers): use descriptive aliases and add tests
Rename single-letter query-builder aliases (C, DT) to readable names
(customer, sales) and add report tests covering the column contract,
validation guards, and the days-since-last-order threshold.
2026-06-08 11:45:34 +05:30
ruthra kumar
bf769a52c0 Merge pull request #55665 from Shllokkk/add-ac-ignore-permissions-fix
fix: drop ignore_permissions handling from add_ac
2026-06-08 11:44:23 +05:30
Kaushal Shriwas
58582cfa09 test: cover user permission scoping in receivable report 2026-06-07 22:20:46 +05:30
Kaushal Shriwas
1ef4978a86 fix: apply user permissions to receivable/payable reports 2026-06-07 21:43:49 +05:30
Shllokkk
37d2adc74b fix: drop ignore_permissions handling from add_ac 2026-06-05 20:49:17 +05:30
Shllokkk
5dbf3fdde0 fix: add permission checks in accounts whitelisted methods 2026-06-05 13:52:57 +05:30
pandiyan
4b0b7adeee fix: bypass project permission check when updating consumed material cost 2026-06-05 13:35:40 +05:30
Shllokkk
8de259a669 Merge branch 'develop' into inact-cust-report 2026-06-04 14:57:58 +05:30
Shllokkk
2ecf8b0466 fix(inactive_customers): add allowlist for doctype filter and migrate to qb 2026-06-04 14:55:49 +05:30
Shllokkk
e460e83516 fix: use new_doc with field allowlist in CRM integration endpoints 2026-05-31 18:42:26 +05:30
yash14023
9084570d18 fix: add docstrings and unify update_stock visibility in JS 2026-05-31 11:23:39 +05:30
yash14023
d57786caa2 fix(accounts): unify update_stock visibility logic in JS 2026-05-26 10:24:26 +05:30
yash14023
a2f877cee6 fix(accounts): prevent update_stock on Debit Notes
Extracted validation into validate_debit_note_with_update_stock().
Hide update_stock in JS via set_dynamic_labels() and is_debit_note handler.
Added unit test asserting ValidationError on save.

Fixes #54891
2026-05-26 01:54:06 +05:30
219 changed files with 171505 additions and 85674 deletions

View File

@@ -4,24 +4,46 @@ set -e
cd ~ || exit
sudo apt update
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev
pip install frappe-bench
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
frappeuser=${FRAPPE_USER:-"frappe"}
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
# ---------------------------------------------------------------------------
# Phase 1 — parallelise the three slow, independent setup steps:
# a) system packages b) frappe-bench pip install c) frappe git fetch
# ---------------------------------------------------------------------------
sudo apt update
# apt remove/install must run sequentially but can overlap with pip and git.
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev &
apt_pid=$!
pip install frappe-bench &
pip_pid=$!
mkdir frappe
(
cd frappe
git init
git remote add origin "https://github.com/${frappeuser}/frappe"
git fetch origin "${frappecommitish}" --depth 1
) &
clone_pid=$!
wait $apt_pid
wait $pip_pid
wait $clone_pid
pushd frappe
git init
git remote add origin "https://github.com/${frappeuser}/frappe"
git fetch origin "${frappecommitish}" --depth 1
git checkout FETCH_HEAD
popd
# ---------------------------------------------------------------------------
# Phase 2 — bench init and site setup
# ---------------------------------------------------------------------------
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
mkdir ~/frappe-bench/sites/test_site
@@ -37,6 +59,11 @@ if [ "$DB" == "mariadb" ];then
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
# Belt-and-suspenders: also set performance variables at runtime in case
# MARIADB_EXTRA_FLAGS was not honoured by the container image.
mariadb --host 127.0.0.1 --port 3306 -u root -proot \
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
@@ -51,9 +78,11 @@ fi
install_whktml() {
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
if [ ! -f /tmp/wkhtmltox.deb ]; then
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
fi
sudo apt install /tmp/wkhtmltox.deb
}
install_whktml &
wkpid=$!

View File

@@ -59,6 +59,10 @@ jobs:
env:
TZ: 'Asia/Kolkata'
MARIADB_ROOT_PASSWORD: 'root'
# Disable durability guarantees that are unnecessary in a throwaway CI container.
# innodb_flush_log_at_trx_commit=0 avoids an fsync on every commit (biggest win).
# sync_binlog=0 skips binary-log syncs; innodb_doublewrite=0 skips the doublewrite buffer.
MARIADB_EXTRA_FLAGS: --innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --innodb-doublewrite=0
ports:
- 3306:3306
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
@@ -122,6 +126,12 @@ jobs:
restore-keys: |
${{ runner.os }}-yarn-
- name: Cache wkhtmltopdf
uses: actions/cache@v4
with:
path: /tmp/wkhtmltox.deb
key: wkhtmltox-0.12.6.1-2-jammy-amd64
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
@@ -131,7 +141,14 @@ jobs:
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
- name: Run Tests
run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --lightmode --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} --with-coverage'
run: |
cd ~/frappe-bench/
coverage_flag=""
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
bench --site test_site run-parallel-tests --lightmode --app erpnext \
--total-builds ${{ strategy.job-total }} \
--build-number ${{ matrix.container }} \
$coverage_flag
env:
TYPE: server
@@ -141,6 +158,7 @@ jobs:
run: cat ~/frappe-bench/bench_start.log || true
- name: Upload coverage data
if: ${{ env.WITH_COVERAGE == 'true' }}
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.container }}
@@ -149,6 +167,7 @@ jobs:
coverage:
name: Coverage Wrap Up
needs: test
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Clone

10
.greptile/config.json Normal file
View File

@@ -0,0 +1,10 @@
{
"disabledLabels": [
"conflicts"
],
"context": {
"repos": [
"frappe/frappe"
]
}
}

View File

@@ -592,10 +592,12 @@ def update_account_number(
@frappe.whitelist()
def merge_account(old: str, new: str):
_ensure_idle_system()
# Validate properties before merging
new_account = frappe.get_cached_doc("Account", new)
old_account = frappe.get_cached_doc("Account", old)
new_account.check_permission("write")
old_account.check_permission("write")
if not new_account:
throw(_("Account {0} does not exist").format(new))

View File

@@ -94,6 +94,7 @@ class BankClearance(Document):
invalid_document = []
invalid_cheque_date = []
entries_to_update = []
self.check_permission("write")
def validate_entry(d):
is_valid = True

View File

@@ -518,6 +518,7 @@ def create_internal_transfer(
"""
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
bank_transaction.check_permission("write")
bank_account = frappe.get_cached_value("Bank Account", bank_transaction.bank_account, "account")
company = frappe.get_cached_value("Account", bank_account, "company")
@@ -778,7 +779,6 @@ def create_bulk_payment_entry_and_reconcile(
"""
Create a payment entry and reconcile it with the bank transaction
"""
output = []
for bank_transaction_name in bank_transaction_names:

View File

@@ -374,6 +374,7 @@ def unreconcile_transaction(transaction_name: str | int):
Else, cancel the individual entries
"""
transaction = frappe.get_doc("Bank Transaction", transaction_name)
transaction.check_permission("write")
vouchers_to_cancel = []
@@ -401,6 +402,7 @@ def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type:
"""
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_id)
bank_transaction.check_permission("write")
# Find the voucher in the bank transaction and depending on the action, either remove it or cancel the voucher
for entry in bank_transaction.payment_entries:

View File

@@ -121,7 +121,7 @@ class BisectAccountingStatements(Document):
cur_node.save()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def build_tree(self):
frappe.db.delete("Bisect Nodes")

View File

@@ -17,6 +17,7 @@ frappe.ui.form.on("Budget", {
filters: {
is_group: 0,
company: frm.doc.company,
root_type: ["in", ["Income", "Expense"]],
},
};
});

View File

@@ -6,12 +6,14 @@ frappe.provide("erpnext.cheque_print");
frappe.ui.form.on("Cheque Print Template", {
refresh: function (frm) {
if (!frm.doc.__islocal) {
frm.add_custom_button(
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
function () {
erpnext.cheque_print.view_cheque_print(frm);
}
).addClass("btn-primary");
if (frappe.user.has_role("System Manager")) {
frm.add_custom_button(
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
function () {
erpnext.cheque_print.view_cheque_print(frm);
}
).addClass("btn-primary");
}
$(frm.fields_dict.cheque_print_preview.wrapper).empty();

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "field:bank_name",
"creation": "2016-05-04 14:35:00.402544",
"doctype": "DocType",
@@ -294,7 +295,7 @@
],
"links": [],
"max_attachments": 1,
"modified": "2024-03-27 13:06:44.654989",
"modified": "2026-06-08 12:10:35.829531",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Cheque Print Template",
@@ -325,19 +326,17 @@
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1,
"write": 1
"share": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -48,6 +48,8 @@ class ChequePrintTemplate(Document):
@frappe.whitelist()
def create_or_update_cheque_print_format(template_name: str):
frappe.only_for("System Manager")
if not frappe.db.exists("Print Format", template_name):
cheque_print = frappe.new_doc("Print Format")
cheque_print.update(

View File

@@ -11,22 +11,28 @@ frappe.ui.form.on("Currency Exchange Settings", {
},
callback: function (r) {
if (r && r.message) {
let result = [],
params = {};
if (frm.doc.service_provider == "exchangerate.host") {
let result = ["result"];
let params = {
result = ["result"];
params = {
date: "{transaction_date}",
from: "{from_currency}",
to: "{to_currency}",
};
add_param(frm, r.message, params, result);
} else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) {
let result = ["rates", "{to_currency}"];
let params = {
result = ["rates", "{to_currency}"];
params = {
base: "{from_currency}",
symbols: "{to_currency}",
};
add_param(frm, r.message, params, result);
} else if (frm.doc.service_provider == "frankfurter.dev - v2") {
result = ["rate"];
params = {
date: "{transaction_date}",
};
}
add_param(frm, r.message, params, result);
}
},
});

View File

@@ -78,7 +78,7 @@
"fieldname": "service_provider",
"fieldtype": "Select",
"label": "Service Provider",
"options": "frankfurter.dev\nexchangerate.host\nCustom",
"options": "frankfurter.dev\nexchangerate.host\nfrankfurter.dev - v2\nCustom",
"reqd": 1
},
{
@@ -101,11 +101,10 @@
"label": "Use HTTP Protocol"
}
],
"hide_toolbar": 0,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-16 13:28:21.075743",
"modified": "2026-06-15 11:25:55.873110",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Currency Exchange Settings",
@@ -122,24 +121,11 @@
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Accounts User",
"share": 1,
"write": 1
"share": 1
}
],
"row_format": "Dynamic",

View File

@@ -29,7 +29,7 @@ class CurrencyExchangeSettings(Document):
disabled: DF.Check
req_params: DF.Table[CurrencyExchangeSettingsDetails]
result_key: DF.Table[CurrencyExchangeSettingsResult]
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "Custom"]
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "frankfurter.dev - v2", "Custom"]
url: DF.Data | None
use_http: DF.Check
# end: auto-generated types
@@ -70,6 +70,14 @@ class CurrencyExchangeSettings(Document):
self.append("req_params", {"key": "base", "value": "{from_currency}"})
self.append("req_params", {"key": "symbols", "value": "{to_currency}"})
elif self.service_provider == "frankfurter.dev - v2":
self.set("result_key", [])
self.set("req_params", [])
self.api_endpoint = get_api_endpoint(self.service_provider, self.use_http)
self.append("result_key", {"key": "rate"})
self.append("req_params", {"key": "date", "value": "{transaction_date}"})
def validate_parameters(self):
params = {}
for row in self.req_params:
@@ -105,13 +113,20 @@ class CurrencyExchangeSettings(Document):
@frappe.whitelist()
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]:
if service_provider and service_provider in [
"exchangerate.host",
"frankfurter.dev",
"frankfurter.app",
"frankfurter.dev - v2",
]:
if service_provider == "exchangerate.host":
api = "api.exchangerate.host/convert"
elif service_provider == "frankfurter.app":
api = "api.frankfurter.app/{transaction_date}"
elif service_provider == "frankfurter.dev":
api = "api.frankfurter.dev/v1/{transaction_date}"
elif service_provider == "frankfurter.dev - v2":
api = "api.frankfurter.dev/v2/rate/{from_currency}/{to_currency}"
protocol = "https://"
if use_http:

View File

@@ -5,7 +5,7 @@ import frappe
from frappe.utils import add_days, flt, nowdate
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.journal_entry.journal_entry import get_payment_entry_against_invoice
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
from erpnext.tests.utils import ERPNextTestSuite

View File

@@ -178,7 +178,7 @@ frappe.ui.form.on("Journal Entry", {
voucher_type: frm.doc.voucher_type,
company: args.company,
},
method: "erpnext.accounts.doctype.journal_entry.journal_entry.make_inter_company_journal_entry",
method: "erpnext.accounts.doctype.journal_entry.mapper.make_inter_company_journal_entry",
callback: function (r) {
if (r.message) {
var doc = frappe.model.sync(r.message)[0];
@@ -409,18 +409,16 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
}
get_outstanding(doctype, docname, company, child) {
var args = {
doctype: doctype,
docname: docname,
party: child.party,
account: child.account,
account_currency: child.account_currency,
company: company,
};
return frappe.call({
method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_outstanding",
args: { args: args },
args: {
doctype: doctype,
docname: docname,
company: company,
account: child.account,
party: child.party,
account_currency: child.account_currency,
},
callback: function (r) {
if (r.message) {
$.each(r.message, function (field, value) {
@@ -731,7 +729,7 @@ $.extend(erpnext.journal_entry, {
reverse_journal_entry: function (frm) {
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.journal_entry.journal_entry.make_reverse_journal_entry",
method: "erpnext.accounts.doctype.journal_entry.mapper.make_reverse_journal_entry",
frm: frm,
});
},

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,261 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Document builders that map a source document to a Journal Entry or to a
Payment Entry raised against it."""
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import flt, get_link_to_form, nowdate
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
get_party_account_based_on_invoice_discounting,
)
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import get_account_currency
@frappe.whitelist()
def get_payment_entry_against_order(
dt: str,
dn: str,
amount: float | None = None,
debit_in_account_currency: str | float | None = None,
journal_entry: bool = False,
bank_account: str | None = None,
) -> dict | Document:
"""Build an advance-payment Journal Entry against an unbilled Sales/Purchase Order."""
ref_doc = frappe.get_doc(dt, dn)
if flt(ref_doc.per_billed, 2) > 0:
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
if dt == "Sales Order":
party_type = "Customer"
amount_field_party = "credit_in_account_currency"
amount_field_bank = "debit_in_account_currency"
else:
party_type = "Supplier"
amount_field_party = "debit_in_account_currency"
amount_field_bank = "credit_in_account_currency"
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
party_account_currency = get_account_currency(party_account)
if not amount:
if party_account_currency == ref_doc.company_currency:
amount = flt(ref_doc.base_grand_total) - flt(ref_doc.advance_paid)
else:
amount = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid)
return get_payment_entry(
ref_doc,
{
"party_type": party_type,
"party_account": party_account,
"party_account_currency": party_account_currency,
"amount_field_party": amount_field_party,
"amount_field_bank": amount_field_bank,
"amount": amount,
"debit_in_account_currency": debit_in_account_currency,
"remarks": f"Advance Payment received against {dt} {dn}",
"is_advance": "Yes",
"bank_account": bank_account,
"journal_entry": journal_entry,
},
)
@frappe.whitelist()
def get_payment_entry_against_invoice(
dt: str,
dn: str,
amount: float | None = None,
debit_in_account_currency: str | None = None,
journal_entry: bool = False,
bank_account: str | None = None,
) -> dict | Document:
"""Build a payment Journal Entry against a Sales/Purchase Invoice's outstanding amount."""
ref_doc = frappe.get_doc(dt, dn)
if dt == "Sales Invoice":
party_type = "Customer"
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
else:
party_type = "Supplier"
party_account = ref_doc.credit_to
if (dt == "Sales Invoice" and ref_doc.outstanding_amount > 0) or (
dt == "Purchase Invoice" and ref_doc.outstanding_amount < 0
):
amount_field_party = "credit_in_account_currency"
amount_field_bank = "debit_in_account_currency"
else:
amount_field_party = "debit_in_account_currency"
amount_field_bank = "credit_in_account_currency"
return get_payment_entry(
ref_doc,
{
"party_type": party_type,
"party_account": party_account,
"party_account_currency": ref_doc.party_account_currency,
"amount_field_party": amount_field_party,
"amount_field_bank": amount_field_bank,
"amount": amount if amount else abs(ref_doc.outstanding_amount),
"debit_in_account_currency": debit_in_account_currency,
"remarks": f"Payment received against {dt} {dn}. {ref_doc.remarks}",
"is_advance": "No",
"bank_account": bank_account,
"journal_entry": journal_entry,
},
)
def get_payment_entry(ref_doc, args: dict) -> dict | Document:
"""Build a Bank Entry Journal Entry paying `ref_doc`, with a party row and a bank row.
Returns the Journal Entry document when `args["journal_entry"]` is truthy, otherwise its
dict (for client calls).
"""
je = frappe.new_doc("Journal Entry")
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
"Company", ref_doc.company, "cost_center"
)
exchange_rate = _reference_exchange_rate(ref_doc, args)
party_row = _append_party_row(je, ref_doc, args, cost_center, exchange_rate)
bank_row = _append_bank_row(je, ref_doc, args, cost_center, exchange_rate)
if party_row.account_currency != ref_doc.company_currency or (
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
):
je.multi_currency = 1
je.set_amounts_in_company_currency()
je.set_total_debit_credit()
return je if args.get("journal_entry") else je.as_dict()
def _reference_exchange_rate(ref_doc, args: dict) -> float:
"""Exchange rate of the party account on the reference document's posting date."""
if not args.get("party_account"):
return 1
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
return get_exchange_rate(
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
args.get("party_account"),
args.get("party_account_currency"),
ref_doc.company,
ref_doc.doctype,
ref_doc.name,
)
def _append_party_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
"""Append the party (debtor/creditor) row that records the advance/payment."""
return je.append(
"accounts",
{
"account": args.get("party_account"),
"party_type": args.get("party_type"),
"party": ref_doc.get(args.get("party_type").lower()),
"cost_center": cost_center,
"account_type": frappe.get_cached_value("Account", args.get("party_account"), "account_type"),
"account_currency": args.get("party_account_currency")
or get_account_currency(args.get("party_account")),
"exchange_rate": exchange_rate,
args.get("amount_field_party"): args.get("amount"),
"is_advance": args.get("is_advance"),
"reference_type": ref_doc.doctype,
"reference_name": ref_doc.name,
},
)
def _append_bank_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
"""Append the bank/cash row, defaulting the account and converting the amount to it."""
from erpnext.accounts.doctype.journal_entry.journal_entry import (
get_default_bank_cash_account,
get_exchange_rate,
)
bank_row = je.append("accounts")
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
if bank_account:
bank_row.update(bank_account)
# posting date assumed to be the reference document's posting/transaction date
bank_row.exchange_rate = get_exchange_rate(
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
bank_account["account"],
bank_account["account_currency"],
ref_doc.company,
)
bank_row.cost_center = cost_center
amount = args.get("debit_in_account_currency") or args.get("amount")
if bank_row.account_currency == args.get("party_account_currency"):
bank_row.set(args.get("amount_field_bank"), amount)
else:
bank_row.set(args.get("amount_field_bank"), amount * exchange_rate)
return bank_row
@frappe.whitelist()
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str) -> dict:
"""Build the counterpart Journal Entry in another company, linked back to `name`."""
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = voucher_type
journal_entry.company = company
journal_entry.posting_date = nowdate()
journal_entry.inter_company_journal_entry_reference = name
return journal_entry.as_dict()
@frappe.whitelist()
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None) -> Document:
"""Map a submitted Journal Entry to a reversing one (debits and credits swapped)."""
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
if existing_reverse:
frappe.throw(
_("A Reverse Journal Entry {0} already exists for this Journal Entry.").format(
get_link_to_form("Journal Entry", existing_reverse)
)
)
from frappe.model.mapper import get_mapped_doc
def post_process(source, target) -> None:
target.reversal_of = source.name
doclist = get_mapped_doc(
"Journal Entry",
source_name,
{
"Journal Entry": {"doctype": "Journal Entry", "validation": {"docstatus": ["=", 1]}},
"Journal Entry Account": {
"doctype": "Journal Entry Account",
"field_map": {
"account_currency": "account_currency",
"exchange_rate": "exchange_rate",
"debit_in_account_currency": "credit_in_account_currency",
"debit": "credit",
"credit_in_account_currency": "debit_in_account_currency",
"credit": "debit",
"reference_type": "reference_type",
"reference_name": "reference_name",
},
},
},
target_doc,
post_process,
)
return doclist

View File

@@ -0,0 +1,200 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _
from frappe.utils import flt
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
get_depr_schedule,
)
class AssetService:
"""Keeps Assets in sync with the Journal Entries that depreciate, dispose or
adjust them.
On submit of a Depreciation Entry it reduces the asset value and links the
depreciation schedule; on submit of an Asset Disposal it marks the asset
disposed. On cancel it reverses those links. It also guards cancellation of
Journal Entries tied to asset scrapping or value adjustments.
"""
def __init__(self, doc) -> None:
self.doc = doc
def validate_depr_account_and_depr_entry_voucher_type(self) -> None:
"""A depreciation account requires voucher type Depreciation Entry and an Expense account."""
for d in self.doc.get("accounts"):
if d.account_type == "Depreciation":
if self.doc.voucher_type != "Depreciation Entry":
frappe.throw(
_("Journal Entry type should be set as Depreciation Entry for asset depreciation")
)
if frappe.get_cached_value("Account", d.account, "root_type") != "Expense":
frappe.throw(_("Account {0} should be of type Expense").format(d.account))
def has_asset_adjustment_entry(self) -> None:
"""Block cancellation while a submitted Asset Value Adjustment links to this entry."""
if self.doc.flags.get("via_asset_value_adjustment"):
return
asset_value_adjustment = frappe.db.get_value(
"Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.doc.name}, "name"
)
if asset_value_adjustment:
frappe.throw(
_(
"Cannot cancel this document as it is linked with the submitted Asset Value Adjustment <b>{0}</b>. Please cancel the Asset Value Adjustment to continue."
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
)
def update_asset_value(self) -> None:
"""Apply the entry's effect to its linked assets on submit (depreciation or disposal)."""
self.update_asset_on_depreciation()
self.update_asset_on_disposal()
def update_asset_on_depreciation(self) -> None:
"""Reduce each depreciated asset's value and link the depreciation schedule row."""
if self.doc.voucher_type != "Depreciation Entry":
return
for d in self.doc.get("accounts"):
if (
d.reference_type == "Asset"
and d.reference_name
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
and d.debit
):
asset = frappe.get_cached_doc("Asset", d.reference_name)
if asset.calculate_depreciation:
self.update_journal_entry_link_on_depr_schedule(asset, d)
self.update_value_after_depreciation(asset, d.debit)
asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit)
asset.set_status()
asset.set_total_booked_depreciations()
def update_value_after_depreciation(self, asset, depr_amount: float) -> None:
"""Subtract the depreciation amount from the asset's relevant finance book."""
fb_idx = 1
if self.doc.finance_book:
for fb_row in asset.get("finance_books"):
if fb_row.finance_book == self.doc.finance_book:
fb_idx = fb_row.idx
break
fb_row = asset.get("finance_books")[fb_idx - 1]
fb_row.value_after_depreciation -= depr_amount
frappe.db.set_value(
"Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation
)
def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None:
"""Stamp this entry onto the matching (date + amount) depreciation schedule row."""
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
for d in depr_schedule or []:
if (
d.schedule_date == self.doc.posting_date
and not d.journal_entry
and d.depreciation_amount == flt(je_row.debit)
):
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)
def update_asset_on_disposal(self) -> None:
"""Mark each referenced asset disposed (date + scrap entry) on an Asset Disposal."""
if self.doc.voucher_type == "Asset Disposal":
disposed_assets = []
for d in self.doc.get("accounts"):
if (
d.reference_type == "Asset"
and d.reference_name
and d.reference_name not in disposed_assets
):
frappe.db.set_value(
"Asset",
d.reference_name,
{
"disposal_date": self.doc.posting_date,
"journal_entry_for_scrap": self.doc.name,
},
)
asset_doc = frappe.get_doc("Asset", d.reference_name)
asset_doc.set_status()
disposed_assets.append(d.reference_name)
def unlink_asset_reference(self) -> None:
"""On cancel, reverse depreciation links and block cancelling an asset-scrap entry."""
for d in self.doc.get("accounts"):
if self._is_depreciation_asset_row(d):
self._reverse_asset_depreciation(d)
elif (
self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name
):
self._block_scrap_journal_cancel(d)
def _is_depreciation_asset_row(self, d) -> bool:
return bool(
self.doc.voucher_type == "Depreciation Entry"
and d.reference_type == "Asset"
and d.reference_name
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
and d.debit
)
def _reverse_asset_depreciation(self, d) -> None:
"""Add the depreciation amount back to the asset and unlink its schedule row."""
asset = frappe.get_doc("Asset", d.reference_name)
if asset.calculate_depreciation and not self._restore_scheduled_depreciation(asset, d.debit):
self._restore_finance_book_value(asset, d.debit)
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
asset.set_status()
asset.set_total_booked_depreciations()
def _restore_scheduled_depreciation(self, asset, debit: float) -> bool:
"""Unlink this entry from the depreciation schedule and credit back its finance book.
Returns True if a matching scheduled depreciation was found.
"""
for fb_row in asset.get("finance_books"):
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
for s in depr_schedule or []:
if s.journal_entry == self.doc.name:
s.db_set("journal_entry", None)
fb_row.value_after_depreciation += debit
fb_row.db_update()
return True
return False
def _restore_finance_book_value(self, asset, debit: float) -> None:
"""Credit the depreciation amount back to the relevant finance book when no schedule matched."""
fb_idx = 1
if self.doc.finance_book:
for fb_row in asset.get("finance_books"):
if fb_row.finance_book == self.doc.finance_book:
fb_idx = fb_row.idx
break
fb_row = asset.get("finance_books")[fb_idx - 1]
fb_row.value_after_depreciation += debit
fb_row.db_update()
def _block_scrap_journal_cancel(self, d) -> None:
"""Prevent cancelling a plain Journal Entry that is an asset's scrap voucher."""
journal_entry_for_scrap = frappe.db.get_value("Asset", d.reference_name, "journal_entry_for_scrap")
if journal_entry_for_scrap == self.doc.name:
frappe.throw(
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
)
def unlink_asset_adjustment_entry(self) -> None:
"""Detach this entry from any Asset Value Adjustment that referenced it."""
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
(
frappe.qb.update(AssetValueAdjustment)
.set(AssetValueAdjustment.journal_entry, None)
.where(AssetValueAdjustment.journal_entry == self.doc.name)
).run()

View File

@@ -18,86 +18,88 @@ class JournalEntryGLComposer(BaseGLComposer):
from the first foreign-currency row (mirroring the former build_gl_map).
"""
def compose(self):
doc = self.doc
gl_map = []
company_currency = erpnext.get_company_currency(doc.company)
doc.transaction_currency = company_currency
doc.transaction_exchange_rate = 1
if doc.multi_currency:
for row in doc.get("accounts"):
if row.account_currency != company_currency:
# Journal assumes the first foreign currency as transaction currency
doc.transaction_currency = row.account_currency
doc.transaction_exchange_rate = row.exchange_rate
break
def compose(self) -> list:
"""Project the Journal Entry's non-zero account rows into GL dicts."""
self._set_transaction_currency()
advance_doctypes = get_advance_payment_doctypes()
for d in doc.get("accounts"):
if d.debit or d.credit or (doc.voucher_type == "Exchange Gain Or Loss"):
r = [d.user_remark, doc.remark]
r = [x for x in r if x]
remarks = "\n".join(r)
row = {
"account": d.account,
"party_type": d.party_type,
"due_date": doc.due_date,
"party": d.party,
"against": d.against_account,
"debit": flt(d.debit, d.precision("debit")),
"credit": flt(d.credit, d.precision("credit")),
"account_currency": d.account_currency,
"debit_in_account_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
),
"credit_in_account_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
),
"transaction_currency": doc.transaction_currency,
"transaction_exchange_rate": doc.transaction_exchange_rate,
"debit_in_transaction_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
"credit_in_transaction_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
"against_voucher_type": d.reference_type,
"against_voucher": d.reference_name,
"remarks": remarks,
"voucher_detail_no": d.reference_detail_no,
"cost_center": d.cost_center,
"project": d.project,
"finance_book": doc.finance_book,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
}
if d.reference_type in advance_doctypes:
row.update(
{
"against_voucher_type": doc.doctype,
"against_voucher": doc.name,
"advance_voucher_type": d.reference_type,
"advance_voucher_no": d.reference_name,
}
)
# set flag to skip party validation
account_type = frappe.get_cached_value("Account", d.account, "account_type")
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
frappe.flags.party_not_required = True
gl_map.append(
self.get_gl_dict(
row,
item=d,
)
)
gl_map = []
for d in self.doc.get("accounts"):
if d.debit or d.credit or self.doc.voucher_type == "Exchange Gain Or Loss":
gl_map.append(self.get_gl_dict(self._gl_row(d, advance_doctypes), item=d))
return gl_map
def _set_transaction_currency(self) -> None:
"""Company currency, or the first foreign-currency row, becomes the transaction currency."""
doc = self.doc
doc.transaction_currency = erpnext.get_company_currency(doc.company)
doc.transaction_exchange_rate = 1
if not doc.multi_currency:
return
for row in doc.get("accounts"):
if row.account_currency != doc.transaction_currency:
# Journal assumes the first foreign currency as transaction currency
doc.transaction_currency = row.account_currency
doc.transaction_exchange_rate = row.exchange_rate
break
def _gl_row(self, d, advance_doctypes: list) -> dict:
"""Build the GL dict for a single account row."""
doc = self.doc
remarks = "\n".join(x for x in [d.user_remark, doc.remark] if x)
row = {
"account": d.account,
"party_type": d.party_type,
"due_date": doc.due_date,
"party": d.party,
"against": d.against_account,
"debit": flt(d.debit, d.precision("debit")),
"credit": flt(d.credit, d.precision("credit")),
"account_currency": d.account_currency,
"debit_in_account_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
),
"credit_in_account_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
),
"transaction_currency": doc.transaction_currency,
"transaction_exchange_rate": doc.transaction_exchange_rate,
"debit_in_transaction_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
"credit_in_transaction_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
"against_voucher_type": d.reference_type,
"against_voucher": d.reference_name,
"remarks": remarks,
"voucher_detail_no": d.reference_detail_no,
"cost_center": d.cost_center,
"project": d.project,
"finance_book": doc.finance_book,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
}
if d.reference_type in advance_doctypes:
row.update(
{
"against_voucher_type": doc.doctype,
"against_voucher": doc.name,
"advance_voucher_type": d.reference_type,
"advance_voucher_no": d.reference_name,
}
)
# set flag to skip party validation
account_type = frappe.get_cached_value("Account", d.account, "account_type")
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
frappe.flags.party_not_required = True
return row

View File

@@ -0,0 +1,199 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _, scrub
from frappe.utils import cstr, flt, fmt_money
from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
get_party_account_based_on_invoice_discounting,
)
from erpnext.accounts.utils import get_account_currency
REFERENCE_PARTY_ACCOUNT_FIELDS = {
"Sales Invoice": ["Customer", "Debit To"],
"Purchase Invoice": ["Supplier", "Credit To"],
"Sales Order": ["Customer"],
"Purchase Order": ["Supplier"],
}
class JournalEntryReferenceValidator:
"""Validates Journal Entry account rows against their referenced documents.
For each row that links a Sales/Purchase Invoice or Order, this checks the
debit/credit direction, party and account match, and aggregates per-reference
totals (held on the document as ``reference_totals``/``reference_types``/
``reference_accounts``) which are then validated against the referenced
orders and invoices.
"""
def __init__(self, doc) -> None:
self.doc = doc
def validate(self) -> None:
"""Validate every reference-bearing row, then the referenced orders and invoices."""
self.doc.reference_totals = {}
self.doc.reference_types = {}
self.doc.reference_accounts = {}
for row in self.doc.get("accounts"):
self._normalize_reference_fields(row)
if not self._has_party_reference(row):
continue
self._validate_order_direction(row)
self._register_reference(row)
self._validate_reference_party_and_account(row)
self._validate_orders()
self._validate_invoices()
def _normalize_reference_fields(self, row) -> None:
if not row.reference_type:
row.reference_name = None
if not row.reference_name:
row.reference_type = None
def _has_party_reference(self, row) -> bool:
return bool(
row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS
)
def _reference_amount_field(self, row) -> str:
if row.reference_type in ("Sales Order", "Sales Invoice"):
return "credit_in_account_currency"
return "debit_in_account_currency"
def _validate_order_direction(self, row) -> None:
"""An order can only be linked on the side that records an advance."""
if row.reference_type == "Sales Order" and flt(row.debit) > 0:
frappe.throw(
_("Row {0}: Debit entry can not be linked with a {1}").format(row.idx, row.reference_type)
)
if row.reference_type == "Purchase Order" and flt(row.credit) > 0:
frappe.throw(
_("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type)
)
def _register_reference(self, row) -> None:
"""Aggregate the row's amount, type and account onto the per-reference lookups."""
if row.reference_name not in self.doc.reference_totals:
self.doc.reference_totals[row.reference_name] = 0.0
if self.doc.voucher_type not in ("Deferred Revenue", "Deferred Expense"):
self.doc.reference_totals[row.reference_name] += flt(row.get(self._reference_amount_field(row)))
self.doc.reference_types[row.reference_name] = row.reference_type
self.doc.reference_accounts[row.reference_name] = row.account
def _validate_reference_party_and_account(self, row) -> None:
"""Reject a missing reference, then check party/account against the linked document."""
party_fields = REFERENCE_PARTY_ACCOUNT_FIELDS[row.reference_type]
against_voucher = frappe.db.get_value(
row.reference_type, row.reference_name, [scrub(f) for f in party_fields]
)
if not against_voucher:
frappe.throw(_("Row {0}: Invalid reference {1}").format(row.idx, row.reference_name))
if row.reference_type in ("Sales Invoice", "Purchase Invoice"):
self._validate_invoice_party_and_account(row, against_voucher, party_fields)
elif row.reference_type in ("Sales Order", "Purchase Order"):
self._validate_order_party(row, against_voucher)
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields) -> None:
party_account, against_party = self._resolve_invoice_party_account(row, against_voucher)
if self.doc.voucher_type == "Exchange Gain Or Loss":
return
if against_party != cstr(row.party) or party_account != row.account:
frappe.throw(
_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format(
row.idx, party_fields[0], party_fields[1], row.reference_type, row.reference_name
)
)
def _resolve_invoice_party_account(self, row, against_voucher) -> tuple:
"""Expected (party_account, party) for an invoice row, honouring deferred booking
and invoice-discounting accounts."""
if self.doc.voucher_type in ("Deferred Revenue", "Deferred Expense") and row.reference_detail_no:
debit_or_credit = "Debit" if row.debit else "Credit"
party_account = get_deferred_booking_accounts(
row.reference_type, row.reference_detail_no, debit_or_credit
)
return party_account, ""
if row.reference_type == "Sales Invoice":
party_account = (
get_party_account_based_on_invoice_discounting(row.reference_name) or against_voucher[1]
)
else:
party_account = against_voucher[1]
return party_account, against_voucher[0]
def _validate_order_party(self, row, against_voucher) -> None:
if against_voucher != row.party:
frappe.throw(
_("Row {0}: {1} {2} does not match with {3}").format(
row.idx, row.party_type, row.party, row.reference_type
)
)
def _validate_orders(self) -> None:
"""Validate totals, closed and docstatus for referenced orders."""
for reference_name, total in self.doc.reference_totals.items():
reference_type = self.doc.reference_types[reference_name]
account = self.doc.reference_accounts[reference_name]
if reference_type not in ("Sales Order", "Purchase Order"):
continue
order = frappe.get_doc(reference_type, reference_name)
self._validate_order_status(order, reference_type, reference_name)
self._validate_order_advance_total(order, account, total, reference_type, reference_name)
def _validate_order_status(self, order, reference_type, reference_name) -> None:
if order.docstatus != 1:
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
if flt(order.per_billed) >= 100:
frappe.throw(_("{0} {1} is fully billed").format(reference_type, reference_name))
if cstr(order.status) == "Closed":
frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name))
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name) -> None:
"""The advance paid against an order cannot exceed its grand total."""
account_currency = get_account_currency(account)
if account_currency == self.doc.company_currency:
voucher_total = order.base_grand_total
field = "base_grand_total"
else:
voucher_total = order.grand_total
field = "grand_total"
if flt(voucher_total) < (flt(order.advance_paid) + total):
formatted_voucher_total = fmt_money(
voucher_total, order.precision(field), currency=account_currency
)
frappe.throw(
_("Advance paid against {0} {1} cannot be greater than Grand Total {2}").format(
reference_type, reference_name, formatted_voucher_total
)
)
def _validate_invoices(self) -> None:
"""Validate totals and docstatus for referenced invoices."""
if self.doc.voucher_type in ("Debit Note", "Credit Note"):
return
for reference_name, total in self.doc.reference_totals.items():
reference_type = self.doc.reference_types[reference_name]
if reference_type not in ("Sales Invoice", "Purchase Invoice"):
continue
invoice = frappe.get_doc(reference_type, reference_name)
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
"""Payment booked against an invoice cannot exceed its outstanding amount."""
if invoice.docstatus != 1:
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
precision = invoice.precision("outstanding_amount")
if total and flt(invoice.outstanding_amount, precision) < flt(total, precision):
frappe.throw(
_("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format(
reference_type, reference_name, invoice.outstanding_amount
)
)

View File

@@ -169,8 +169,11 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency",
"credit",
"credit_in_account_currency",
"debit_in_transaction_currency",
"credit_in_transaction_currency",
]
# Transaction currency is USD (first foreign row); the INR row is converted at 1/50.
self.expected_gle = [
{
"account": "_Test Bank - _TC",
@@ -179,6 +182,8 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency": 0,
"credit": 5000,
"credit_in_account_currency": 5000,
"debit_in_transaction_currency": 0,
"credit_in_transaction_currency": 100,
},
{
"account": "_Test Bank USD - _TC",
@@ -187,6 +192,8 @@ class TestJournalEntry(ERPNextTestSuite):
"debit_in_account_currency": 100,
"credit": 0,
"credit_in_account_currency": 0,
"debit_in_transaction_currency": 100,
"credit_in_transaction_currency": 0,
},
]
@@ -203,8 +210,54 @@ class TestJournalEntry(ERPNextTestSuite):
self.assertFalse(gle)
def test_multi_currency_transaction_currency_on_foreign_debit(self):
"""Pin debit_in_transaction_currency for a foreign-currency debit row.
Transaction currency is USD (the first foreign row); the INR debit row must be
converted at 1/exchange_rate, so 5000 INR -> 100 USD. Guards the / vs * direction.
"""
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.multi_currency = 1
jv.append(
"accounts",
{
"account": "_Test Bank USD - _TC",
"cost_center": "_Test Cost Center - _TC",
"credit_in_account_currency": 100,
"exchange_rate": 50,
},
)
jv.append(
"accounts",
{
"account": "_Test Bank - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit_in_account_currency": 5000,
"exchange_rate": 1,
},
)
jv.submit()
self.voucher_no = jv.name
self.fields = ["account", "debit_in_transaction_currency", "credit_in_transaction_currency"]
self.expected_gle = [
{
"account": "_Test Bank - _TC",
"debit_in_transaction_currency": 100,
"credit_in_transaction_currency": 0,
},
{
"account": "_Test Bank USD - _TC",
"debit_in_transaction_currency": 0,
"credit_in_transaction_currency": 100,
},
]
self.check_gl_entries()
def test_reverse_journal_entry(self):
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
jv = make_journal_entry("_Test Bank USD - _TC", "Sales - _TC", 100, exchange_rate=50, save=False)
@@ -609,6 +662,181 @@ class TestJournalEntry(ERPNextTestSuite):
jv.save()
self.assertRaises(frappe.ValidationError, jv.submit)
def test_party_not_allowed_for_non_receivable_payable_account(self):
customer = make_customer("_Test New Customer")
jv = make_journal_entry(account1="_Test Cash - _TC", account2="_Test Bank - _TC", amount=100, save=False)
jv.accounts[0].party_type = "Customer"
jv.accounts[0].party = customer
self.assertRaises(frappe.ValidationError, jv.save)
def test_validate_reference_doc_debit_against_sales_order_throws(self):
"""Characterize: a debit entry linked to a Sales Order is rejected."""
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
sales_order = make_sales_order()
jv = make_journal_entry("Debtors - _TC", "_Test Cash - _TC", 100, save=False)
jv.accounts[0].party_type = "Customer"
jv.accounts[0].party = "_Test Customer"
jv.accounts[0].reference_type = "Sales Order"
jv.accounts[0].reference_name = sales_order.name
self.assertRaisesRegex(frappe.ValidationError, "Debit entry can not be linked", jv.insert)
def test_validate_reference_doc_credit_against_purchase_order_throws(self):
"""Characterize: a credit entry linked to a Purchase Order is rejected."""
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
purchase_order = create_purchase_order()
jv = make_journal_entry("_Test Cash - _TC", "Creditors - _TC", 100, save=False)
jv.accounts[1].party_type = "Supplier"
jv.accounts[1].party = "_Test Supplier"
jv.accounts[1].reference_type = "Purchase Order"
jv.accounts[1].reference_name = purchase_order.name
self.assertRaisesRegex(frappe.ValidationError, "Credit entry can not be linked", jv.insert)
def test_validate_reference_doc_nonexistent_reference_rejected(self):
"""Characterize: a JE referencing a non-existent invoice is rejected by link validation.
Note: the controller's own "Invalid reference" branch is unreachable in normal flow
because Frappe link validation rejects the missing reference before validate_reference_doc.
"""
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
jv.accounts[1].party_type = "Customer"
jv.accounts[1].party = "_Test Customer"
jv.accounts[1].reference_type = "Sales Invoice"
jv.accounts[1].reference_name = "NON-EXISTENT-SI"
self.assertRaises(frappe.LinkValidationError, jv.insert)
def test_validate_reference_doc_invoice_party_mismatch_throws(self):
"""Characterize: an invoice reference whose party differs from the row party is rejected."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=500)
other_customer = make_customer("_Test JE Mismatch Customer")
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
jv.accounts[1].party_type = "Customer"
jv.accounts[1].party = other_customer
jv.accounts[1].reference_type = "Sales Invoice"
jv.accounts[1].reference_name = invoice.name
self.assertRaisesRegex(frappe.ValidationError, "Party / Account does not match", jv.insert)
def test_validate_reference_doc_order_party_mismatch_throws(self):
"""Characterize: a Sales Order reference whose party differs from the row party is rejected."""
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
sales_order = make_sales_order()
other_customer = make_customer("_Test JE Mismatch Customer")
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
jv.accounts[1].party_type = "Customer"
jv.accounts[1].party = other_customer
jv.accounts[1].is_advance = "Yes"
jv.accounts[1].reference_type = "Sales Order"
jv.accounts[1].reference_name = sales_order.name
self.assertRaisesRegex(frappe.ValidationError, "does not match", jv.insert)
def test_validate_reference_doc_populates_reference_side_effects(self):
"""Characterize: a valid invoice reference populates reference_totals/types/accounts."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=500)
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
jv.accounts[1].party_type = "Customer"
jv.accounts[1].party = "_Test Customer"
jv.accounts[1].reference_type = "Sales Invoice"
jv.accounts[1].reference_name = invoice.name
jv.insert()
self.assertEqual(jv.reference_totals[invoice.name], 100.0)
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
def test_get_balance_places_difference_on_blank_row(self):
"""Characterize: get_balance puts the unbalanced difference on an amountless row."""
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.append(
"accounts",
{
"account": "_Test Cash - _TC",
"debit_in_account_currency": 100,
"debit": 100,
"exchange_rate": 1,
},
)
jv.append("accounts", {"account": "_Test Bank - _TC", "exchange_rate": 1}) # amountless row
jv.set_total_debit_credit()
self.assertEqual(jv.difference, 100)
jv.get_balance()
blank_row = jv.accounts[1]
self.assertEqual(blank_row.credit_in_account_currency, 100)
self.assertEqual(jv.total_debit, jv.total_credit)
def test_get_outstanding_invoices_builds_write_off_rows(self):
"""Characterize: get_outstanding_invoices adds a party row for each outstanding invoice."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=700)
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.voucher_type = "Write Off Entry"
jv.write_off_based_on = "Accounts Receivable"
jv.write_off_amount = 1000
jv.get_outstanding_invoices()
invoice_rows = [row for row in jv.accounts if row.reference_name == invoice.name]
self.assertTrue(invoice_rows)
self.assertEqual(invoice_rows[0].party_type, "Customer")
self.assertEqual(invoice_rows[0].reference_type, "Sales Invoice")
self.assertEqual(flt(invoice_rows[0].credit_in_account_currency), 700)
def test_unlink_advance_entry_reference_on_cancel(self):
"""Characterize: cancelling an advance JE against an invoice clears the row's reference."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=700)
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
advance_row = jv.accounts[1]
advance_row.party_type = "Customer"
advance_row.party = "_Test Customer"
advance_row.is_advance = "Yes"
advance_row.reference_type = "Sales Invoice"
advance_row.reference_name = invoice.name
jv.submit()
jv.cancel()
jv.reload()
self.assertFalse(jv.accounts[1].reference_type)
self.assertFalse(jv.accounts[1].reference_name)
def test_get_payment_entry_against_order_builds_advance_je(self):
"""Characterize the mapper: an advance Bank Entry JE is built against an unbilled order."""
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_order
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
sales_order = make_sales_order()
je = get_payment_entry_against_order("Sales Order", sales_order.name, journal_entry=True)
self.assertEqual(je.voucher_type, "Bank Entry")
party_rows = [row for row in je.accounts if row.party_type == "Customer"]
self.assertTrue(party_rows)
self.assertEqual(party_rows[0].reference_type, "Sales Order")
self.assertEqual(party_rows[0].reference_name, sales_order.name)
self.assertEqual(party_rows[0].is_advance, "Yes")
def test_make_inter_company_journal_entry_builds_linked_draft(self):
"""Characterize the mapper: the counterpart JE carries the company and back-reference."""
from erpnext.accounts.doctype.journal_entry.mapper import make_inter_company_journal_entry
source = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, submit=True)
result = make_inter_company_journal_entry(
source.name, "Inter Company Journal Entry", "_Test Company 1"
)
self.assertEqual(result.get("voucher_type"), "Inter Company Journal Entry")
self.assertEqual(result.get("company"), "_Test Company 1")
self.assertEqual(result.get("inter_company_journal_entry_reference"), source.name)
def make_journal_entry(
account1,

View File

@@ -2036,6 +2036,9 @@ def get_outstanding_reference_documents(args: str | dict, validate: bool = False
if args.get("party_type") == "Member":
return
if args.get("party_type") and args.get("party"):
frappe.has_permission(args["party_type"], "read", args["party"], throw=True)
if not args.get("get_outstanding_invoices") and not args.get("get_orders_to_be_billed"):
args["get_outstanding_invoices"] = True
@@ -2531,6 +2534,7 @@ def get_reference_details(
):
total_amount = outstanding_amount = exchange_rate = account = None
frappe.has_permission(reference_doctype, "read", reference_name, throw=True)
ref_doc = frappe.get_lazy_doc(reference_doctype, reference_name)
company_currency = ref_doc.get("company_currency") or erpnext.get_company_currency(ref_doc.company)
@@ -2776,7 +2780,7 @@ def get_payment_entry(
pe, doc, discount_amount, base_total_discount_loss, party_account_currency
)
pe.set_exchange_rate(ref_doc=doc)
pe.set_exchange_rate()
pe.set_amounts()
# If PE is created from PR directly, then no need to find open PRs for the references

View File

@@ -532,6 +532,8 @@ class TestPaymentEntry(ERPNextTestSuite):
si.submit()
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC", bank_amount=4700)
pe.source_exchange_rate = 50
pe.set_amounts()
pe.reference_no = si.name
pe.reference_date = nowdate()
@@ -607,6 +609,8 @@ class TestPaymentEntry(ERPNextTestSuite):
pe = get_payment_entry(
"Sales Invoice", si.name, party_amount=20, bank_account="_Test Bank - _TC", bank_amount=900
)
pe.source_exchange_rate = 50
pe.set_amounts()
pe.reference_no = "1"
pe.reference_date = "2016-01-01"

View File

@@ -11,11 +11,12 @@ from erpnext import get_company_currency
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.doctype.bank_account.bank_account import get_party_bank_account
from erpnext.accounts.doctype.payment_entry.payment_entry import (
get_payment_entry,
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.accounts.party import get_party_account, get_party_bank_account
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes, get_currency_precision
from erpnext.utilities import payment_app_import_guard

View File

@@ -332,7 +332,12 @@ class TestPaymentRequest(ERPNextTestSuite):
return_doc=1,
)
pe = pr.set_as_paid()
pe = pr.create_payment_entry(submit=False)
pe.source_exchange_rate = 50
pe.target_exchange_rate = 50
pe.set_amounts()
pe.insert(ignore_permissions=True)
pe.submit()
expected_gle = dict(
(d[0], d)
@@ -418,7 +423,12 @@ class TestPaymentRequest(ERPNextTestSuite):
pr = make_payment_request(dt=po_doc.doctype, dn=po_doc.name, recipient_id="nabin@erpnext.com")
pr = frappe.get_doc(pr).save().submit()
pe = pr.create_payment_entry()
pe = pr.create_payment_entry(submit=False)
pe.target_exchange_rate = 80
pe.paid_amount = 800
pe.set_amounts()
pe.insert(ignore_permissions=True)
pe.submit()
self.assertEqual(pe.base_paid_amount, 800)
self.assertEqual(pe.paid_amount, 800)
self.assertEqual(pe.base_received_amount, 800)

View File

@@ -21,6 +21,7 @@ from erpnext.accounts.doctype.sales_invoice.services.loyalty import LoyaltyServi
from erpnext.accounts.party import get_due_date, get_party_account
from erpnext.controllers.queries import item_query as _item_query
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.stock_ledger import is_negative_stock_allowed
@@ -403,7 +404,7 @@ class POSInvoice(SalesInvoice):
for d in self.get("items"):
if not d.serial_and_batch_bundle:
if frappe.db.exists("Product Bundle", d.item_code):
if get_active_product_bundle(d.item_code):
(
availability,
is_stock_item,
@@ -916,7 +917,7 @@ def get_stock_availability(item_code: str | None, warehouse: str):
return bin_qty - pos_sales_qty, is_stock_item, is_negative_stock_allowed(item_code=item_code)
else:
is_stock_item = True
if frappe.db.exists("Product Bundle", {"name": item_code, "disabled": 0}):
if get_active_product_bundle(item_code):
return get_bundle_availability(item_code, warehouse), is_stock_item, False
else:
is_stock_item = False
@@ -926,7 +927,7 @@ def get_stock_availability(item_code: str | None, warehouse: str):
def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
is_stock_item = True
bundle = frappe.get_doc("Product Bundle", item_code)
bundle = frappe.get_doc("Product Bundle", get_active_product_bundle(item_code))
availabilities = []
for bundle_item in bundle.items:
if frappe.get_value("Item", bundle_item.item_code, "is_stock_item"):
@@ -945,7 +946,7 @@ def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
def get_bundle_availability(bundle_item_code, warehouse):
product_bundle = frappe.get_doc("Product Bundle", bundle_item_code)
product_bundle = frappe.get_doc("Product Bundle", get_active_product_bundle(bundle_item_code))
bundle_bin_qty = 1000000
for item in product_bundle.items:

View File

@@ -10,6 +10,8 @@
"barcode",
"has_item_scanned",
"item_code",
"is_product_bundle",
"product_bundle",
"col_break1",
"item_name",
"customer_item_code",
@@ -125,6 +127,23 @@
"options": "Item",
"search_index": 1
},
{
"default": "0",
"fieldname": "is_product_bundle",
"fieldtype": "Check",
"hidden": 1,
"label": "Is Product Bundle",
"print_hide": 1,
"read_only": 1
},
{
"depends_on": "eval:doc.is_product_bundle",
"fieldname": "product_bundle",
"fieldtype": "Link",
"label": "Product Bundle",
"options": "Product Bundle",
"read_only_depends_on": "eval:doc.so_detail"
},
{
"fieldname": "col_break1",
"fieldtype": "Column Break"
@@ -858,7 +877,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-04-20 16:16:12.322024",
"modified": "2026-06-08 20:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice Item",

View File

@@ -315,32 +315,3 @@ def pos_profile_query(doctype: str, txt: str, searchfield: str, start: int, page
)
return pos_profile
@frappe.whitelist()
def set_default_profile(pos_profile: str, company: str):
modified = now()
user = frappe.session.user
if pos_profile and company:
frappe.db.sql(
""" update `tabPOS Profile User` pfu, `tabPOS Profile` pf
set
pfu.default = 0, pf.modified = %s, pf.modified_by = %s
where
pfu.user = %s and pf.name = pfu.parent and pf.company = %s
and pfu.default = 1""",
(modified, user, user, company),
auto_commit=1,
)
frappe.db.sql(
""" update `tabPOS Profile User` pfu, `tabPOS Profile` pf
set
pfu.default = 1, pf.modified = %s, pf.modified_by = %s
where
pfu.user = %s and pf.name = pfu.parent and pf.company = %s and pf.name = %s
""",
(modified, user, user, company, pos_profile),
auto_commit=1,
)

View File

@@ -131,6 +131,7 @@ def is_job_running(job_name: str) -> bool:
@frappe.whitelist()
def pause_job_for_doc(docname: str | None = None):
if docname:
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
frappe.db.set_value("Process Payment Reconciliation", docname, "status", "Paused")
log = frappe.db.get_value("Process Payment Reconciliation Log", filters={"process_pr": docname})
if log:
@@ -145,6 +146,8 @@ def trigger_job_for_doc(docname: str | None = None):
if not docname:
return
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
if not frappe.get_single_value("Accounts Settings", "auto_reconcile_payments"):
frappe.throw(
_("Auto Reconciliation of Payments has been disabled. Enable it through {0}").format(

View File

@@ -92,6 +92,7 @@ class ProcessPeriodClosingVoucher(Document):
@frappe.whitelist()
def start_pcv_processing(docname: str):
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
ppcvd = qb.DocType("Process Period Closing Voucher Detail")

View File

@@ -101,6 +101,7 @@ class ProcessStatementOfAccounts(Document):
validate_template(self.subject)
validate_template(self.body)
validate_template(self.pdf_name)
if not self.customers:
frappe.throw(_("Customers not selected."))
@@ -521,6 +522,7 @@ def download_statements(document_name: str):
@frappe.whitelist()
def send_emails(document_name: str, from_scheduler: bool = False, posting_date: str | None = None):
doc = frappe.get_doc("Process Statement Of Accounts", document_name)
doc.check_permission()
report = get_report_pdf(doc, consolidated=False)
if report:
@@ -577,6 +579,7 @@ def send_emails(document_name: str, from_scheduler: bool = False, posting_date:
@frappe.whitelist()
def send_auto_email():
frappe.has_permission("Process Statement Of Accounts", throw=True)
selected = frappe.get_list(
"Process Statement Of Accounts",
filters={"enable_auto_email": 1},

View File

@@ -614,10 +614,12 @@
{
"default": "0",
"depends_on": "eval:doc.items.every((item) => !item.pr_detail)",
"description": "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Purchase Receipt is created separately.",
"fieldname": "update_stock",
"fieldtype": "Check",
"label": "Update Stock",
"print_hide": 1
"print_hide": 1,
"show_description_on_click": 1
},
{
"fieldname": "scan_barcode",
@@ -1690,7 +1692,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-05-28 12:36:55.215363",
"modified": "2026-06-13 18:36:46.704623",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -653,6 +653,9 @@ class PurchaseInvoice(BuyingController):
self.process_common_party_accounting()
if self.is_return:
self.refresh_subscription_status()
def on_update_after_submit(self):
fields_to_check = [
"cash_bank_account",
@@ -772,6 +775,8 @@ class PurchaseInvoice(BuyingController):
"Tax Withholding Entry",
)
self.refresh_subscription_status()
def update_project(self):
projects = frappe._dict()
for d in self.items:
@@ -934,9 +939,9 @@ def make_regional_gl_entries(gl_entries, doc):
@frappe.whitelist()
def change_release_date(name: str, release_date: str | None = None):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.db_set("release_date", release_date)
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.check_permission()
pi.db_set("release_date", release_date)
@frappe.whitelist()

View File

@@ -51,16 +51,6 @@ class ExpenseAccountService:
if doc.update_stock and item.warehouse and (not item.from_warehouse):
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
if for_validate and item.expense_account and item.expense_account != _inv_dict["account"]:
msg = _(
"Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account"
).format(
item.idx,
frappe.bold(_inv_dict["account"]),
frappe.bold(item.expense_account),
frappe.bold(item.warehouse),
)
frappe.msgprint(msg, title=_("Expense Head Changed"))
item.expense_account = _inv_dict["account"]
else:
# check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not

View File

@@ -886,8 +886,10 @@
"read_only": 1
},
{
"description": "Product Bundle version this row was packed from",
"fieldname": "product_bundle",
"fieldtype": "Link",
"hidden": 1,
"label": "Product Bundle",
"options": "Product Bundle",
"read_only": 1
@@ -1008,7 +1010,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-05-06 08:08:40.782395",
"modified": "2026-06-08 21:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",

View File

@@ -158,6 +158,7 @@ def start_repost(account_repost_doc: str | None = None) -> None:
frappe.flags.through_repost_accounting_ledger = True
if account_repost_doc:
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
repost_doc.check_permission("write")
if repost_doc.docstatus == 1:
# Prevent repost on invoices with deferred accounting

View File

@@ -327,7 +327,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
"rate": "rate",
},
"postprocess": update_item,
"condition": lambda doc: doc.qty > 0,
"condition": lambda doc: doc.qty - received_items.get(doc.name, 0.0) > 0,
}
if doctype in ["Sales Invoice", "Sales Order"]:
@@ -367,11 +367,19 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
target_doc,
set_missing_values,
)
if not doclist.get("items"):
frappe.throw(
_(
"Cannot create Intercompany {0}. All items in the source {1} have already been fully invoiced. "
"Please check the existing linked {2}s."
).format(target_doctype, doctype, target_doctype)
)
return doclist
def get_received_items(reference_name, doctype, reference_fieldname):
@frappe.whitelist()
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
reference_field = "inter_company_invoice_reference"
if doctype == "Purchase Order":
reference_field = "inter_company_order_reference"
@@ -384,20 +392,19 @@ def get_received_items(reference_name, doctype, reference_fieldname):
target_doctypes = frappe.get_all(
doctype,
filters=filters,
as_list=True,
pluck="name",
)
received_items_map = {}
if target_doctypes:
target_doctypes = list(target_doctypes[0])
received_items_map = frappe._dict(
frappe.get_all(
received_items_data = frappe.get_all(
doctype + " Item",
filters={"parent": ("in", target_doctypes)},
fields=[reference_fieldname, "qty"],
as_list=1,
)
)
for item in received_items_data:
key = item.get(reference_fieldname)
if key:
received_items_map[key] = received_items_map.get(key, 0.0) + flt(item.qty)
return received_items_map

View File

@@ -179,12 +179,31 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
: "Inter Company Purchase Invoice";
me.frm.add_custom_button(
button_label,
__(button_label),
function () {
me.make_inter_company_invoice();
},
__("Create")
);
frappe.call({
method: "erpnext.accounts.doctype.sales_invoice.mapper.get_received_items",
args: {
reference_name: me.frm.doc.name,
doctype: "Purchase Invoice",
reference_fieldname: "sales_invoice_item",
},
callback: function (r) {
if (r.exc) return;
const received_items = r.message || {};
const has_pending_qty = me.frm.doc.items.some(
(item) => flt(item.qty) - flt(received_items[item.name] || 0) > 0
);
if (!has_pending_qty) {
me.frm.remove_custom_button(__(button_label), __("Create"));
}
},
});
}
}
@@ -567,6 +586,8 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
set_dynamic_labels() {
super.set_dynamic_labels();
this.frm.events.hide_fields(this.frm);
const hide_update_stock = cint(this.frm.doc.is_debit_note) || cint(this.frm.doc.has_subcontracted);
this.frm.set_df_property("update_stock", "hidden", hide_update_stock);
}
items_on_form_rendered() {
@@ -1155,13 +1176,20 @@ frappe.ui.form.on("Sales Invoice", {
);
},
is_debit_note: function (frm) {
if (frm.doc.is_debit_note) {
frm.set_value("update_stock", 0);
}
// visibility handled by set_dynamic_labels()
frm.cscript.set_dynamic_labels();
},
refresh: function (frm) {
if (frm.doc.is_debit_note) {
frm.set_df_property("return_against", "label", __("Adjustment Against"));
}
frm.set_df_property("update_stock", "read_only", frm.doc.has_subcontracted);
frm.toggle_display("update_stock", !frm.doc.has_subcontracted);
},
});

View File

@@ -715,6 +715,7 @@
{
"default": "0",
"depends_on": "eval:doc.items.every((item) => !item.dn_detail)",
"description": "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Delivery Note is created separately.",
"fieldname": "update_stock",
"fieldtype": "Check",
"hide_days": 1,
@@ -722,7 +723,8 @@
"label": "Update Stock",
"oldfieldname": "update_stock",
"oldfieldtype": "Check",
"print_hide": 1
"print_hide": 1,
"show_description_on_click": 1
},
{
"fieldname": "scan_barcode",

View File

@@ -304,6 +304,7 @@ class SalesInvoice(SellingController):
self.validate_uom_is_integer("uom", "qty")
self.check_sales_order_on_hold_or_close("sales_order")
self.validate_debit_to_acc()
self.validate_debit_note_with_update_stock()
self.clear_unallocated_advances("Sales Invoice Advance", "advances")
FixedAssetService(self).validate_fixed_asset()
FixedAssetService(self).set_income_account_for_fixed_assets()
@@ -411,8 +412,8 @@ class SalesInvoice(SellingController):
validate_account_head(item.idx, item.income_account, self.company, _("Income"))
def before_save(self):
POSService(self).update_paid_amount()
POSService(self).set_account_for_mode_of_payment()
POSService(self).set_paid_amount()
def before_submit(self):
self.add_remarks()
@@ -497,6 +498,9 @@ class SalesInvoice(SellingController):
self.process_common_party_accounting()
self.update_billed_qty_in_scio()
if self.is_return:
self.refresh_subscription_status()
def before_cancel(self):
POSService(self).check_if_created_using_pos_and_pos_closing_entry_generated()
POSService(self).check_if_consolidated_invoice()
@@ -584,6 +588,7 @@ class SalesInvoice(SellingController):
POSService(self).cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode()
self.update_billed_qty_in_scio()
self.refresh_subscription_status()
def update_status_updater_args(self):
if not cint(self.update_stock):
@@ -956,6 +961,17 @@ class SalesInvoice(SellingController):
if flt(self.change_amount) and not self.account_for_change_amount:
msgprint(_("Please enter Account for Change Amount"), raise_exception=1)
def validate_debit_note_with_update_stock(self):
"""Prevent stock update when Sales Invoice is marked as Debit Note."""
if self.is_debit_note and cint(self.update_stock):
frappe.throw(
_(
"You cannot update stock for a Debit Note. A Debit Note is a financial "
"document that should not affect inventory. Please disable 'Update Stock'."
),
title=_("Invalid Configuration"),
)
def validate_dropship_item(self):
"""If items are drop shipped, stock cannot be updated."""
if not cint(self.update_stock):

View File

@@ -114,10 +114,17 @@ class POSService:
return pos
def set_paid_amount(self) -> None:
def update_paid_amount(self) -> None:
doc = self.doc
paid_amount = 0.0
base_paid_amount = 0.0
if not cint(doc.is_pos) and doc.is_return:
doc.set("payments", [])
doc.paid_amount = paid_amount
doc.base_paid_amount = base_paid_amount
return
for data in doc.payments:
data.base_amount = flt(data.amount * doc.conversion_rate, doc.precision("base_paid_amount"))
paid_amount += data.amount

View File

@@ -2918,6 +2918,67 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(target_doc.company, "_Test Company 1")
self.assertEqual(target_doc.supplier, "_Test Internal Supplier")
def test_restrict_inter_company_pi_when_sales_invoice_qty_fully_consumed(self):
item_code_1 = "_Test IC Item 1"
item_code_2 = "_Test IC Item 2"
create_item(item_code_1, is_stock_item=1)
create_item(item_code_2, is_stock_item=1)
si = create_sales_invoice(
company="Wind Power LLC",
customer="_Test Internal Customer",
item_code=item_code_1,
debit_to="Debtors - WP",
warehouse="Stores - WP",
income_account="Sales - WP",
expense_account="Cost of Goods Sold - WP",
cost_center="Main - WP",
currency="USD",
qty=3,
do_not_save=1,
)
si.selling_price_list = "_Test Price List Rest of the World"
si.append(
"items",
{
"item_code": item_code_2,
"item_name": item_code_2,
"description": item_code_2,
"warehouse": "Stores - WP",
"qty": 2,
"uom": "Nos",
"stock_uom": "Nos",
"rate": 100,
"price_list_rate": 100,
"income_account": "Sales - WP",
"expense_account": "Cost of Goods Sold - WP",
"cost_center": "Main - WP",
"conversion_factor": 1,
},
)
si.submit()
target_doc = make_inter_company_transaction("Sales Invoice", si.name)
for item in target_doc.items:
item.update(
{
"expense_account": "Cost of Goods Sold - _TC1",
"cost_center": "Main - _TC1",
}
)
target_doc.submit()
self.assertEqual(len(target_doc.items), 2)
self.assertEqual([item.qty for item in target_doc.items], [3, 2])
with self.assertRaisesRegex(
frappe.ValidationError,
"already been fully invoiced",
):
make_inter_company_transaction("Sales Invoice", si.name)
def test_inter_company_transaction_does_not_inherit_party_fields(self):
"""
Party-derived fields on SI (from Customer) must not leak into the mapped PI.
@@ -5152,6 +5213,13 @@ class TestSalesInvoice(ERPNextTestSuite):
frappe.db.set_value("Company", "_Test Company 1", "cost_center", cost_center)
def test_debit_note_with_update_stock_validation(self):
"""Test that saving a Debit Note with Update Stock enabled raises ValidationError."""
si = create_sales_invoice(do_not_save=True)
si.is_debit_note = 1
si.update_stock = 1
self.assertRaises(frappe.ValidationError, si.save)
def make_item_for_si(item_code, properties=None):
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -10,6 +10,8 @@
"barcode",
"has_item_scanned",
"item_code",
"is_product_bundle",
"product_bundle",
"col_break1",
"item_name",
"customer_item_code",
@@ -144,6 +146,23 @@
"options": "Item",
"search_index": 1
},
{
"default": "0",
"fieldname": "is_product_bundle",
"fieldtype": "Check",
"hidden": 1,
"label": "Is Product Bundle",
"print_hide": 1,
"read_only": 1
},
{
"depends_on": "eval:doc.is_product_bundle",
"fieldname": "product_bundle",
"fieldtype": "Link",
"label": "Product Bundle",
"options": "Product Bundle",
"read_only_depends_on": "eval:doc.so_detail || doc.dn_detail"
},
{
"fieldname": "col_break1",
"fieldtype": "Column Break"
@@ -1036,7 +1055,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-03 13:17:36.145788",
"modified": "2026-06-08 20:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",

View File

@@ -29,7 +29,13 @@ frappe.ui.form.on("Subscription", {
},
refresh: function (frm) {
if (frm.is_new()) return;
if (frm.is_new()) {
// The field wrapper is reused across docs; clear any stale heatmap.
frm.get_field("billing_heatmap").$wrapper.empty();
return;
}
frm.trigger("render_billing_heatmap");
if (frm.doc.status !== "Cancelled") {
frm.add_custom_button(
@@ -95,4 +101,88 @@ frappe.ui.form.on("Subscription", {
}
});
},
render_billing_heatmap: function (frm) {
frm.call("get_billing_heatmap").then((r) => {
if (!r.message || !r.message.length) return;
render_heatmap(frm.get_field("billing_heatmap").$wrapper, r.message, frm.doc);
});
},
});
// Status -> colour and label for the calendar heatmap. Keys are Title-case to
// match the value frappe-charts shows in its hover tooltip.
const HEATMAP_COLORS = {
Paid: "#39d353",
Unpaid: "#388bfd",
Overdue: "#f0883e",
Cancelled: "#f85149",
Refunded: "#a371f7",
Planned: "#87ceeb",
};
// Days inside the window but outside the subscription's active span stay faded.
const EMPTY_COLOR = "#ebedf0";
function title_case(status) {
return status.charAt(0).toUpperCase() + status.slice(1);
}
function render_heatmap($wrapper, days, doc) {
const data_points = {};
days.forEach((day) => {
data_points[day.date] = title_case(day.status);
});
$wrapper.empty();
const chart_el = $('<div class="subscription-billing-heatmap"></div>').appendTo($wrapper)[0];
new frappe.Chart(chart_el, {
type: "heatmap",
data: {
dataPoints: data_points,
start: new Date(days[0].date),
end: new Date(days[days.length - 1].date),
},
discreteDomains: 1,
showLegend: 0,
// frappe-charts only does an intensity scale; we recolour each square by
// its own status below, so the scale colours are placeholders.
colors: ["#ebedf0", "#ebedf0", "#ebedf0", "#ebedf0", "#ebedf0"],
});
// Paint every day square with its status colour (data-value holds the status).
// The chart re-renders once for its entry animation, so repaint on each redraw.
const within_subscription = (date) =>
(!doc.start_date || date >= doc.start_date) && (!doc.end_date || date <= doc.end_date);
const paint = () =>
chart_el.querySelectorAll("[data-date]").forEach((square) => {
const status = square.getAttribute("data-value");
if (status === "Planned" && !within_subscription(square.getAttribute("data-date"))) {
// Outside the subscription's span: render blank and drop the status so the
// hover tooltip shows only the date, not "Planned".
square.setAttribute("fill", EMPTY_COLOR);
square.setAttribute("data-value", "");
return;
}
square.setAttribute("fill", HEATMAP_COLORS[status] || EMPTY_COLOR);
});
paint();
new MutationObserver(paint).observe(chart_el, { childList: true, subtree: true });
const legend = Object.keys(HEATMAP_COLORS)
.map(
(status) =>
`<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;">
<span style="width:11px;height:11px;border-radius:2px;background:${HEATMAP_COLORS[status]};"></span>
${__(status)}
</span>`
)
.join("");
$(`<div style="margin-top:8px;font-size:11px;color:var(--text-muted);">${legend}</div>`).appendTo(
$wrapper
);
}

View File

@@ -6,6 +6,9 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"billing_history_section",
"billing_heatmap",
"section_break_jznv",
"party_type",
"party",
"cb_1",
@@ -21,12 +24,16 @@
"generate_new_invoices_past_due_date",
"submit_invoice",
"column_break_11",
"current_invoice_start",
"current_invoice_end",
"days_until_due",
"generate_invoice_at",
"number_of_days",
"cancel_at_period_end",
"billing_period_section",
"current_invoice_start",
"current_invoice_end",
"billing_period_cb",
"next_billing_period_start",
"next_billing_period_end",
"sb_4",
"plans",
"sb_1",
@@ -51,7 +58,7 @@
"fieldtype": "Select",
"label": "Status",
"no_copy": 1,
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted",
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted\nRefunded",
"read_only": 1
},
{
@@ -83,17 +90,40 @@
"fieldname": "column_break_11",
"fieldtype": "Column Break"
},
{
"fieldname": "billing_period_section",
"fieldtype": "Section Break",
"label": "Billing Period"
},
{
"fieldname": "current_invoice_start",
"fieldtype": "Date",
"label": "Current Invoice Start Date",
"label": "Current Invoice Start",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "current_invoice_end",
"fieldtype": "Date",
"label": "Current Invoice End Date",
"label": "Current Invoice End",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "billing_period_cb",
"fieldtype": "Column Break"
},
{
"fieldname": "next_billing_period_start",
"fieldtype": "Date",
"label": "Next Billing Period Start",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "next_billing_period_end",
"fieldtype": "Date",
"label": "Next Billing Period End",
"no_copy": 1,
"read_only": 1
},
@@ -108,7 +138,18 @@
"default": "0",
"fieldname": "cancel_at_period_end",
"fieldtype": "Check",
"label": "Cancel At End Of Period"
"label": "Cancel When Period Ends"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "billing_history_section",
"fieldtype": "Section Break",
"label": "Billing History"
},
{
"fieldname": "billing_heatmap",
"fieldtype": "HTML",
"label": "Billing Heatmap"
},
{
"allow_on_submit": 1,
@@ -206,7 +247,7 @@
"description": "New invoices will be generated as per schedule even if current invoices are unpaid or past due date",
"fieldname": "generate_new_invoices_past_due_date",
"fieldtype": "Check",
"label": "Generate New Invoices Past Due Date"
"label": "Bill Even If Previous Invoice Unpaid"
},
{
"fieldname": "end_date",
@@ -239,19 +280,23 @@
"label": "Submit Generated Invoices"
},
{
"default": "End of the current subscription period",
"default": "Postpaid (bill at period end)",
"fieldname": "generate_invoice_at",
"fieldtype": "Select",
"label": "Generate Invoice At",
"options": "End of the current subscription period\nBeginning of the current subscription period\nDays before the current subscription period",
"options": "Postpaid (bill at period end)\nPrepaid (bill at period start)\nBill N days before period start",
"reqd": 1
},
{
"depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\"",
"depends_on": "eval:doc.generate_invoice_at === \"Bill N days before period start\"",
"fieldname": "number_of_days",
"fieldtype": "Int",
"label": "Number of Days",
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\""
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Bill N days before period start\""
},
{
"fieldname": "section_break_jznv",
"fieldtype": "Section Break"
}
],
"index_web_pages_for_search": 1,
@@ -267,11 +312,11 @@
"link_fieldname": "subscription"
}
],
"modified": "2025-12-23 19:42:52.036034",
"modified": "2026-06-04 07:21:15.938170",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscription",
"naming_rule": "Expression (old style)",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{

View File

@@ -14,6 +14,7 @@ from frappe.utils.data import (
cint,
date_diff,
flt,
get_first_day,
get_last_day,
get_link_to_form,
getdate,
@@ -35,6 +36,24 @@ class InvoiceNotCancelled(frappe.ValidationError):
pass
GENERATE_AT_END = "Postpaid (bill at period end)"
GENERATE_AT_BEGINNING = "Prepaid (bill at period start)"
GENERATE_AT_DAYS_BEFORE = "Bill N days before period start"
STATUS_TRIALING = "Trialing"
STATUS_ACTIVE = "Active"
STATUS_GRACE_PERIOD = "Grace Period"
STATUS_CANCELLED = "Cancelled"
STATUS_UNPAID = "Unpaid"
STATUS_COMPLETED = "Completed"
STATUS_REFUNDED = "Refunded"
PARTY_CUSTOMER = "Customer"
PARTY_SUPPLIER = "Supplier"
INVOICE_PAID = "Paid"
DateTimeLikeObject = str | date
@@ -64,11 +83,13 @@ class Subscription(Document):
end_date: DF.Date | None
follow_calendar_months: DF.Check
generate_invoice_at: DF.Literal[
"End of the current subscription period",
"Beginning of the current subscription period",
"Days before the current subscription period",
"Postpaid (bill at period end)",
"Prepaid (bill at period start)",
"Bill N days before period start",
]
generate_new_invoices_past_due_date: DF.Check
next_billing_period_end: DF.Date | None
next_billing_period_start: DF.Date | None
number_of_days: DF.Int
party: DF.DynamicLink
party_type: DF.Link
@@ -76,7 +97,9 @@ class Subscription(Document):
purchase_tax_template: DF.Link | None
sales_tax_template: DF.Link | None
start_date: DF.Date | None
status: DF.Literal["", "Trialing", "Active", "Grace Period", "Cancelled", "Unpaid", "Completed"]
status: DF.Literal[
"", "Trialing", "Active", "Grace Period", "Cancelled", "Unpaid", "Completed", "Refunded"
]
submit_invoice: DF.Check
trial_period_end: DF.Date | None
trial_period_start: DF.Date | None
@@ -103,38 +126,39 @@ class Subscription(Document):
or an outstanding invoice blocks billing (per `generate_new_invoices_past_due_date`).
"""
while getdate(self._next_invoice_trigger_date()) <= getdate(nowdate()):
period_start = self.current_invoice_start
period_start = self.next_billing_period_start
self.process(posting_date=self._next_invoice_trigger_date())
if self.status == "Cancelled" or getdate(self.current_invoice_start) == getdate(period_start):
if self.status == STATUS_CANCELLED or getdate(self.next_billing_period_start) == getdate(
period_start
):
break
if not self.generate_new_invoices_past_due_date:
break
def _next_invoice_trigger_date(self) -> DateTimeLikeObject:
if self.generate_invoice_at == "Beginning of the current subscription period":
return self.current_invoice_start
if self.generate_invoice_at == "Days before the current subscription period":
return add_days(self.current_invoice_start, -self.number_of_days)
return self.current_invoice_end
return self._invoice_date_for_period(self.next_billing_period_start, self.next_billing_period_end)
def _invoice_date_for_period(
self, period_start: DateTimeLikeObject, period_end: DateTimeLikeObject
) -> DateTimeLikeObject:
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
return period_start
if self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
return add_days(period_start, -self.number_of_days)
return period_end
def update_subscription_period(self, date: DateTimeLikeObject | None = None):
"""
Subscription period is the period to be billed. This method updates the
beginning of the billing period and end of the billing period.
The beginning of the billing period is represented in the doctype as
`current_invoice_start` and the end of the billing period is represented
as `current_invoice_end`.
`next_billing_period_start` and the end of the billing period is represented
as `next_billing_period_end`.
"""
self.current_invoice_start = self.get_current_invoice_start(date)
self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start)
def _get_subscription_period(self, date: DateTimeLikeObject | None = None):
_current_invoice_start = self.get_current_invoice_start(date)
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
return _current_invoice_start, _current_invoice_end
self.next_billing_period_start = self.get_current_invoice_start(date)
self.next_billing_period_end = self.get_current_invoice_end(self.next_billing_period_start)
def get_current_invoice_start(self, date: DateTimeLikeObject | None = None) -> DateTimeLikeObject:
"""
@@ -175,7 +199,7 @@ class Subscription(Document):
_current_invoice_end = add_to_date(self.start_date, **billing_cycle_info)
# For cases where trial period is for an entire billing interval
if getdate(self.current_invoice_end) < getdate(date):
if getdate(self.next_billing_period_end) < getdate(date):
_current_invoice_end = add_to_date(date, **billing_cycle_info)
else:
_current_invoice_end = add_to_date(date, **billing_cycle_info)
@@ -253,21 +277,35 @@ class Subscription(Document):
"""
Sets the status of the `Subscription`
"""
self._set_current_invoice_dates()
if self.is_trialling():
self.status = "Trialing"
self.status = STATUS_TRIALING
elif self.is_fully_refunded() and self.has_outstanding_invoice():
self.status = STATUS_REFUNDED
elif (
not self.has_outstanding_invoice()
and self.end_date
and getdate(posting_date) > getdate(self.end_date)
):
self.status = "Completed"
self.status = STATUS_COMPLETED
elif self.is_past_grace_period():
self.status = self.get_status_for_past_grace_period()
self.cancelation_date = getdate(posting_date) if self.status == "Cancelled" else None
self.cancelation_date = getdate(posting_date) if self.status == STATUS_CANCELLED else None
elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
self.status = "Grace Period"
self.status = STATUS_GRACE_PERIOD
elif not self.has_outstanding_invoice():
self.status = "Active"
self.status = STATUS_ACTIVE
def _set_current_invoice_dates(self) -> None:
invoice = frappe.get_all(
self.invoice_document_type,
filters={"subscription": self.name, "docstatus": ("<", 2), "is_return": 0},
fields=["from_date", "to_date"],
order_by="to_date desc",
limit=1,
)
self.current_invoice_start = invoice[0].from_date if invoice else None
self.current_invoice_end = invoice[0].to_date if invoice else None
def is_trialling(self) -> bool:
"""
@@ -282,7 +320,6 @@ class Subscription(Document):
"""
Returns true if the given `end_date` has passed
"""
# todo: test for illegal time
if not end_date:
return True
@@ -290,10 +327,10 @@ class Subscription(Document):
def get_status_for_past_grace_period(self) -> str:
cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
status = "Unpaid"
status = STATUS_UNPAID
if cancel_after_grace:
status = "Cancelled"
status = STATUS_CANCELLED
return status
@@ -321,7 +358,7 @@ class Subscription(Document):
@property
def invoice_document_type(self) -> str:
return "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
return "Sales Invoice" if self.party_type == PARTY_CUSTOMER else "Purchase Invoice"
def validate(self) -> None:
self.validate_trial_period()
@@ -413,11 +450,7 @@ class Subscription(Document):
to_date: DateTimeLikeObject | None = None,
posting_date: DateTimeLikeObject | None = None,
) -> Document:
"""
Creates a `Invoice` for the `Subscription`, updates `self.invoices` and
saves the `Subscription`.
Backwards compatibility
"""
"""Public alias for `create_invoice`; kept for external integrations."""
return self.create_invoice(from_date=from_date, to_date=to_date, posting_date=posting_date)
def create_invoice(
@@ -429,8 +462,19 @@ class Subscription(Document):
"""
Creates a `Invoice`, submits it and returns it
"""
# For backward compatibility
# Earlier subscription didn't had any company field
company = self._resolve_company()
invoice = self._init_invoice_doc(company, posting_date)
self._set_invoice_party(invoice)
self._set_invoice_currency(invoice)
self._apply_accounting_dimensions(invoice)
self._append_invoice_items(invoice)
self._apply_taxes(invoice)
self._apply_payment_schedule(invoice)
self._apply_discounts(invoice)
return self._finalize_invoice(invoice, from_date, to_date)
def _resolve_company(self) -> str:
# Earlier subscriptions didn't have a company field
company = self.get("company") or get_default_company()
if not company:
frappe.throw(
@@ -438,48 +482,49 @@ class Subscription(Document):
"Company is mandatory for generating an invoice. Please set a default company in Global Defaults."
)
)
return company
def _init_invoice_doc(self, company: str, posting_date: DateTimeLikeObject | None = None) -> Document:
invoice = frappe.new_doc(self.invoice_document_type)
invoice.company = company
invoice.set_posting_time = 1
if self.generate_invoice_at == "Beginning of the current subscription period":
invoice.posting_date = self.current_invoice_start
elif self.generate_invoice_at == "Days before the current subscription period":
invoice.posting_date = posting_date or self.current_invoice_start
else:
invoice.posting_date = self.current_invoice_end
invoice.posting_date = self._invoice_posting_date(posting_date)
invoice.cost_center = self.cost_center
return invoice
def _invoice_posting_date(self, posting_date: DateTimeLikeObject | None = None) -> DateTimeLikeObject:
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
return self.next_billing_period_start
if self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
return posting_date or self.next_billing_period_start
return self.next_billing_period_end
def _set_invoice_party(self, invoice: Document) -> None:
if self.invoice_document_type == "Sales Invoice":
invoice.customer = self.party
else:
invoice.supplier = self.party
tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
"Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"]
)
if tax_withholding_category or tax_withholding_group:
invoice.apply_tds = 1
return
# Add currency to invoice
invoice.supplier = self.party
tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
"Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"]
)
if tax_withholding_category or tax_withholding_group:
invoice.apply_tds = 1
def _set_invoice_currency(self, invoice: Document) -> None:
invoice.currency = frappe.db.get_value("Subscription Plan", {"name": self.plans[0].plan}, "currency")
# Add dimensions in invoice for subscription:
accounting_dimensions = get_accounting_dimensions()
for dimension in accounting_dimensions:
def _apply_accounting_dimensions(self, invoice: Document) -> None:
for dimension in get_accounting_dimensions():
if self.get(dimension):
invoice.update({dimension: self.get(dimension)})
# Subscription is better suited for service items. I won't update `update_stock`
# for that reason
items_list = self.get_items_from_plans(self.plans, is_prorate())
for item in items_list:
def _append_invoice_items(self, invoice: Document) -> None:
# Subscription is better suited for service items, so `update_stock` is left untouched
for item in self.get_items_from_plans(self.plans, is_prorate()):
invoice.append("items", item)
# Taxes
def _apply_taxes(self, invoice: Document) -> None:
tax_template = ""
if self.invoice_document_type == "Sales Invoice" and self.sales_tax_template:
@@ -493,37 +538,43 @@ class Subscription(Document):
invoice.taxes_and_charges = tax_template
TaxService(invoice).set_taxes()
# Due date
if self.days_until_due:
invoice.append(
"payment_schedule",
{
"due_date": add_days(invoice.posting_date, cint(self.days_until_due)),
"invoice_portion": 100,
},
)
def _apply_payment_schedule(self, invoice: Document) -> None:
if not self.days_until_due:
return
# Discounts
invoice.append(
"payment_schedule",
{
"due_date": add_days(invoice.posting_date, cint(self.days_until_due)),
"invoice_portion": 100,
},
)
def _apply_discounts(self, invoice: Document) -> None:
if self.is_trialling():
invoice.additional_discount_percentage = 100
else:
if self.additional_discount_percentage:
invoice.additional_discount_percentage = self.additional_discount_percentage
return
if self.additional_discount_amount:
invoice.discount_amount = self.additional_discount_amount
if self.additional_discount_percentage:
invoice.additional_discount_percentage = self.additional_discount_percentage
if self.additional_discount_percentage or self.additional_discount_amount:
discount_on = self.apply_additional_discount
invoice.apply_discount_on = discount_on if discount_on else "Grand Total"
if self.additional_discount_amount:
invoice.discount_amount = self.additional_discount_amount
# Subscription period
if self.additional_discount_percentage or self.additional_discount_amount:
invoice.apply_discount_on = self.apply_additional_discount or "Grand Total"
def _finalize_invoice(
self,
invoice: Document,
from_date: DateTimeLikeObject | None = None,
to_date: DateTimeLikeObject | None = None,
) -> Document:
invoice.subscription = self.name
invoice.from_date = from_date or self.current_invoice_start
invoice.to_date = to_date or self.current_invoice_end
invoice.from_date = from_date or self.next_billing_period_start
invoice.to_date = to_date or self.next_billing_period_end
invoice.flags.ignore_mandatory = True
invoice.set_missing_values()
invoice.save()
@@ -540,15 +591,9 @@ class Subscription(Document):
prorate_factor = 1
if prorate:
prorate_factor = get_prorata_factor(
self.current_invoice_end,
self.current_invoice_start,
cint(
self.generate_invoice_at
in [
"Beginning of the current subscription period",
"Days before the current subscription period",
]
),
self.next_billing_period_end,
self.next_billing_period_start,
cint(self.generate_invoice_at in [GENERATE_AT_BEGINNING, GENERATE_AT_DAYS_BEFORE]),
)
items = []
@@ -558,7 +603,7 @@ class Subscription(Document):
item_code = plan_doc.item
if self.party_type == "Customer":
if self.party_type == PARTY_CUSTOMER:
deferred_field = "enable_deferred_revenue"
else:
deferred_field = "enable_deferred_expense"
@@ -572,8 +617,8 @@ class Subscription(Document):
plan.plan,
plan.qty,
party,
self.current_invoice_start,
self.current_invoice_end,
self.next_billing_period_start,
self.next_billing_period_end,
prorate_factor,
),
"cost_center": plan_doc.cost_center,
@@ -583,8 +628,8 @@ class Subscription(Document):
item.update(
{
deferred_field: deferred,
"service_start_date": self.current_invoice_start,
"service_end_date": self.current_invoice_end,
"service_start_date": self.next_billing_period_start,
"service_end_date": self.next_billing_period_end,
}
)
@@ -607,11 +652,11 @@ class Subscription(Document):
2. `process_for_past_due`
"""
if not self.is_current_invoice_generated(
self.current_invoice_start, self.current_invoice_end
self.next_billing_period_start, self.next_billing_period_end
) and self.can_generate_new_invoice(posting_date):
self.generate_invoice(posting_date=posting_date)
if self.end_date:
next_start = add_days(self.current_invoice_end, 1)
next_start = add_days(self.next_billing_period_end, 1)
if getdate(next_start) > getdate(self.end_date):
if self.cancel_at_period_end:
@@ -621,12 +666,12 @@ class Subscription(Document):
self.save()
return
self.update_subscription_period(add_days(self.current_invoice_end, 1))
elif posting_date and getdate(posting_date) > getdate(self.current_invoice_end):
self.update_subscription_period(add_days(self.next_billing_period_end, 1))
elif posting_date and getdate(posting_date) > getdate(self.next_billing_period_end):
self.update_subscription_period()
if self.cancel_at_period_end and (
getdate(posting_date) >= getdate(self.current_invoice_end)
getdate(posting_date) >= getdate(self.next_billing_period_end)
or getdate(posting_date) >= getdate(self.end_date)
):
self.cancel_subscription()
@@ -652,9 +697,9 @@ class Subscription(Document):
# multi-year gap doesn't retroactively bill cycle after cycle in one call.
billing_cycle_info = self.get_billing_cycle_data()
if billing_cycle_info:
upper = getdate(add_to_date(self.current_invoice_end, **billing_cycle_info))
upper = getdate(add_to_date(self.next_billing_period_end, **billing_cycle_info))
else:
upper = getdate(self.current_invoice_end)
upper = getdate(self.next_billing_period_end)
return posting <= upper
@@ -664,9 +709,8 @@ class Subscription(Document):
_current_end_date: DateTimeLikeObject | None = None,
) -> bool:
if not (_current_start_date and _current_end_date):
_current_start_date, _current_end_date = self._get_subscription_period(
date=add_days(self.current_invoice_end, 1)
)
_current_start_date = self.get_current_invoice_start(add_days(self.next_billing_period_end, 1))
_current_end_date = self.get_current_invoice_end(_current_start_date)
if self.current_invoice and getdate(_current_start_date) <= getdate(
self.current_invoice.posting_date
@@ -688,7 +732,7 @@ class Subscription(Document):
"""
invoice = frappe.get_all(
self.invoice_document_type,
{"subscription": self.name, "docstatus": ("<", 2)},
{"subscription": self.name, "docstatus": ("<", 2), "is_return": 0},
limit=1,
order_by="to_date desc",
pluck="name",
@@ -710,41 +754,70 @@ class Subscription(Document):
"""
Return `True` if the given invoice is paid
"""
return invoice.status == "Paid"
return invoice.status == INVOICE_PAID
def has_outstanding_invoice(self) -> int:
"""
Returns `True` if the most recent invoice for the `Subscription` is not paid
Returns the count of submitted, non-return invoices that are not yet paid.
"""
return frappe.db.count(
self.invoice_document_type,
{
"subscription": self.name,
"docstatus": 1,
"status": ["!=", "Paid"],
"is_return": 0,
"status": ["!=", INVOICE_PAID],
},
)
def is_fully_refunded(self) -> bool:
"""
`True` only when every submitted, not-`Paid` invoice on the subscription has
credit notes whose absolute total covers its outstanding amount.
"""
unpaid_invoices = frappe.get_all(
self.invoice_document_type,
filters={
"subscription": self.name,
"docstatus": 1,
"is_return": 0,
"status": ["!=", INVOICE_PAID],
},
fields=["name", "outstanding_amount"],
)
if not unpaid_invoices:
return False
return all(self._is_invoice_fully_credited(invoice) for invoice in unpaid_invoices)
def _is_invoice_fully_credited(self, invoice: dict) -> bool:
credit_notes = frappe.get_all(
self.invoice_document_type,
filters={"return_against": invoice.name, "docstatus": 1},
pluck="grand_total",
)
credited = sum(flt(amount) for amount in credit_notes)
return abs(credited) >= flt(invoice.outstanding_amount)
@frappe.whitelist()
def cancel_subscription(self) -> None:
"""
This sets the subscription as cancelled. It will stop invoices from being generated
but it will not affect already created invoices.
"""
if self.status == "Cancelled":
if self.status == STATUS_CANCELLED:
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
to_generate_invoice = (
True
if self.status == "Active"
and self.generate_invoice_at != "Beginning of the current subscription period"
if self.status == STATUS_ACTIVE and self.generate_invoice_at != GENERATE_AT_BEGINNING
else False
)
self.status = "Cancelled"
self.status = STATUS_CANCELLED
self.cancelation_date = nowdate()
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.current_invoice_start):
self.generate_invoice(self.current_invoice_start, self.cancelation_date)
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.next_billing_period_start):
self.generate_invoice(self.next_billing_period_start, self.cancelation_date)
self.save()
@@ -755,10 +828,10 @@ class Subscription(Document):
subscription and the `Subscription` will lose all the history of generated invoices
it has.
"""
if self.status != "Cancelled":
if self.status != STATUS_CANCELLED:
frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
self.status = "Active"
self.status = STATUS_ACTIVE
self.cancelation_date = None
self.update_subscription_period(posting_date or nowdate())
self.save()
@@ -766,25 +839,130 @@ class Subscription(Document):
@frappe.whitelist()
def force_fetch_subscription_updates(self):
"""
Process Subscription and create Invoices even if current date doesn't lie between current_invoice_start and currenct_invoice_end
Process Subscription and create Invoices even if current date doesn't lie between next_billing_period_start and next_billing_period_end
It makes use of 'Proces Subscription' to force processing in a specific 'posting_date'
"""
# Don't process future subscriptions
if getdate(nowdate()) < getdate(self.current_invoice_start):
if getdate(nowdate()) < getdate(self.next_billing_period_start):
frappe.msgprint(_("Subscription for Future dates cannot be processed."))
return
processing_date = None
if self.generate_invoice_at == "Beginning of the current subscription period":
processing_date = self.current_invoice_start
elif self.generate_invoice_at == "End of the current subscription period":
processing_date = self.current_invoice_end
elif self.generate_invoice_at == "Days before the current subscription period":
processing_date = add_days(self.current_invoice_start, -self.number_of_days)
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
processing_date = self.next_billing_period_start
elif self.generate_invoice_at == GENERATE_AT_END:
processing_date = self.next_billing_period_end
elif self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
processing_date = add_days(self.next_billing_period_start, -self.number_of_days)
self.process(posting_date=processing_date)
@frappe.whitelist()
def get_billing_heatmap(self) -> list[dict]:
"""
One cell per calendar day for a fixed 12-month window starting at the first day of
the subscription's first month. Each day is coloured by the status of the billing
period it falls into; days with no invoice yet are `planned`.
"""
periods = self._billing_periods()
window_start = get_first_day(self.start_date) if self.start_date else get_first_day(nowdate())
window_end = get_last_day(add_months(window_start, 11))
cells = []
day = window_start
while day <= window_end:
cells.append(self._heatmap_cell(day, periods))
day = add_days(day, 1)
return cells
def _billing_periods(self) -> list[dict]:
invoices = frappe.get_all(
self.invoice_document_type,
filters={"subscription": self.name},
fields=[
"name",
"from_date",
"to_date",
"status",
"due_date",
"grand_total",
"docstatus",
"is_return",
"return_against",
],
order_by="from_date asc",
)
credited = {
invoice.return_against
for invoice in invoices
if invoice.is_return and invoice.docstatus == 1 and invoice.return_against
}
periods = [
{
"period_start": str(invoice.from_date),
"period_end": str(invoice.to_date),
"invoice": invoice.name,
"amount": flt(invoice.grand_total),
"status": self._heatmap_status(invoice, invoice.name in credited),
}
for invoice in invoices
if not invoice.is_return and invoice.from_date and invoice.to_date
]
return [*periods, *self._planned_periods(periods)]
def _heatmap_status(self, invoice: dict, is_credited: bool) -> str:
if invoice.docstatus == 2:
return "cancelled"
if is_credited:
return "refunded"
if invoice.status == INVOICE_PAID:
return "paid"
if invoice.due_date and getdate(invoice.due_date) < getdate(nowdate()):
return "overdue"
return "unpaid"
def _planned_periods(self, invoiced_periods: list[dict]) -> list[dict]:
invoiced = {(period["period_start"], period["period_end"]) for period in invoiced_periods}
planned = []
for start, end in self._upcoming_periods():
if start and end and (str(start), str(end)) not in invoiced:
planned.append(
{
"period_start": str(start),
"period_end": str(end),
"invoice": None,
"amount": 0.0,
"status": "planned",
}
)
return planned
def _upcoming_periods(self) -> list[tuple]:
"""The open billing period and the one immediately after it."""
open_period = (self.next_billing_period_start, self.next_billing_period_end)
after_start = add_days(self.next_billing_period_end, 1) if self.next_billing_period_end else None
after_end = self.get_current_invoice_end(after_start) if after_start else None
return [open_period, (after_start, after_end)]
def _heatmap_cell(self, day: date, periods: list[dict]) -> dict:
for period in periods:
if getdate(period["period_start"]) <= day <= getdate(period["period_end"]):
return {"date": str(day), **period}
return {
"date": str(day),
"status": "planned",
"invoice": None,
"amount": 0.0,
"period_start": None,
"period_end": None,
}
def is_prorate() -> int:
return cint(frappe.db.get_single_value("Subscription Settings", "prorate"))

View File

@@ -11,6 +11,8 @@ from frappe.utils.data import (
date_diff,
flt,
get_date_str,
get_first_day,
get_last_day,
getdate,
nowdate,
)
@@ -35,11 +37,11 @@ class TestSubscription(ERPNextTestSuite):
self.assertEqual(subscription.trial_period_start, nowdate())
self.assertEqual(subscription.trial_period_end, add_months(nowdate(), 1))
self.assertEqual(
add_days(subscription.trial_period_end, 1), get_date_str(subscription.current_invoice_start)
add_days(subscription.trial_period_end, 1), get_date_str(subscription.next_billing_period_start)
)
self.assertEqual(
add_to_date(subscription.current_invoice_start, months=1, days=-1),
get_date_str(subscription.current_invoice_end),
add_to_date(subscription.next_billing_period_start, months=1, days=-1),
get_date_str(subscription.next_billing_period_end),
)
self.assertEqual(subscription.invoices, [])
self.assertEqual(subscription.status, "Trialing")
@@ -48,8 +50,8 @@ class TestSubscription(ERPNextTestSuite):
subscription = create_subscription()
self.assertEqual(subscription.trial_period_start, None)
self.assertEqual(subscription.trial_period_end, None)
self.assertEqual(subscription.current_invoice_start, nowdate())
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(subscription.next_billing_period_start, nowdate())
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
# No invoice is created
self.assertEqual(len(subscription.invoices), 0)
self.assertEqual(subscription.status, "Active")
@@ -66,12 +68,12 @@ class TestSubscription(ERPNextTestSuite):
subscription = create_subscription(start_date="2018-01-01")
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Unpaid")
self.assertEqual(getdate(subscription.current_invoice_start), getdate("2018-02-01"))
self.assertEqual(getdate(subscription.current_invoice_end), getdate("2018-02-28"))
self.assertEqual(getdate(subscription.next_billing_period_start), getdate("2018-02-01"))
self.assertEqual(getdate(subscription.next_billing_period_end), getdate("2018-02-28"))
def test_status_goes_back_to_active_after_invoice_is_paid(self):
subscription = create_subscription(
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
start_date="2018-01-01", generate_invoice_at="Prepaid (bill at period start)"
)
subscription.process(posting_date="2018-01-01") # generate first invoice
self.assertEqual(len(subscription.invoices), 1)
@@ -89,7 +91,7 @@ class TestSubscription(ERPNextTestSuite):
subscription.process()
self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, add_months(subscription.start_date, 1))
self.assertEqual(subscription.next_billing_period_start, add_months(subscription.start_date, 1))
self.assertEqual(len(subscription.invoices), 1)
def test_subscription_cancel_after_grace_period(self):
@@ -122,7 +124,7 @@ class TestSubscription(ERPNextTestSuite):
_date = add_months(nowdate(), -1)
subscription = create_subscription(start_date=_date, days_until_due=10)
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
subscription.process(posting_date=subscription.next_billing_period_end) # generate first invoice
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Active")
@@ -134,7 +136,7 @@ class TestSubscription(ERPNextTestSuite):
subscription = create_subscription(start_date=add_days(nowdate(), -1000))
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
subscription.process(posting_date=subscription.next_billing_period_end) # generate first invoice
self.assertEqual(subscription.status, "Grace Period")
subscription.process()
@@ -154,20 +156,20 @@ class TestSubscription(ERPNextTestSuite):
subscription = create_subscription() # no changes expected
self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, nowdate())
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(subscription.next_billing_period_start, nowdate())
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(len(subscription.invoices), 0)
subscription.process() # no changes expected still
self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, nowdate())
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(subscription.next_billing_period_start, nowdate())
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(len(subscription.invoices), 0)
subscription.process() # no changes expected yet still
self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, nowdate())
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(subscription.next_billing_period_start, nowdate())
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(len(subscription.invoices), 0)
def test_subscription_cancellation(self):
@@ -191,16 +193,18 @@ class TestSubscription(ERPNextTestSuite):
self.assertEqual(len(subscription.invoices), 1)
invoice = subscription.get_current_invoice()
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
plan_days = flt(
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
)
prorate_factor = flt(diff / plan_days)
self.assertEqual(
flt(
get_prorata_factor(
subscription.current_invoice_end,
subscription.current_invoice_start,
cint(subscription.generate_invoice_at == "Beginning of the current subscription period"),
subscription.next_billing_period_end,
subscription.next_billing_period_start,
cint(subscription.generate_invoice_at == "Prepaid (bill at period start)"),
),
2,
),
@@ -237,8 +241,10 @@ class TestSubscription(ERPNextTestSuite):
subscription.cancel_subscription()
invoice = subscription.get_current_invoice()
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
plan_days = flt(
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
)
prorate_factor = flt(diff / plan_days)
self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2))
@@ -303,9 +309,9 @@ class TestSubscription(ERPNextTestSuite):
settings.save()
subscription = create_subscription(
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
start_date="2018-01-01", generate_invoice_at="Prepaid (bill at period start)"
)
subscription.process(subscription.current_invoice_start) # generate first invoice
subscription.process(subscription.next_billing_period_start) # generate first invoice
# This should change status to Unpaid since grace period is 0
self.assertEqual(subscription.status, "Unpaid")
@@ -317,7 +323,7 @@ class TestSubscription(ERPNextTestSuite):
self.assertEqual(subscription.status, "Active")
# A new invoice is generated
subscription.process(posting_date=subscription.current_invoice_start)
subscription.process(posting_date=subscription.next_billing_period_start)
self.assertEqual(subscription.status, "Unpaid")
settings.cancel_after_grace = default_grace_period_action
@@ -354,7 +360,7 @@ class TestSubscription(ERPNextTestSuite):
# Change the subscription type to prebilled and process it.
# Prepaid invoice should be generated
subscription.generate_invoice_at = "Beginning of the current subscription period"
subscription.generate_invoice_at = "Prepaid (bill at period start)"
subscription.save()
subscription.process()
@@ -366,7 +372,7 @@ class TestSubscription(ERPNextTestSuite):
settings.prorate = 1
settings.save()
subscription = create_subscription(generate_invoice_at="Beginning of the current subscription period")
subscription = create_subscription(generate_invoice_at="Prepaid (bill at period start)")
subscription.process()
subscription.cancel_subscription()
@@ -387,7 +393,7 @@ class TestSubscription(ERPNextTestSuite):
subscription.company = "_Test Company"
subscription.party_type = "Supplier"
subscription.party = "_Test Supplier"
subscription.generate_invoice_at = "Beginning of the current subscription period"
subscription.generate_invoice_at = "Prepaid (bill at period start)"
subscription.follow_calendar_months = 1
# select subscription start date as "2018-01-15"
@@ -413,7 +419,7 @@ class TestSubscription(ERPNextTestSuite):
end_date="2018-12-31",
party_type="Supplier",
party="_Test Supplier",
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
generate_new_invoices_past_due_date=1,
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
)
@@ -424,7 +430,7 @@ class TestSubscription(ERPNextTestSuite):
def test_subscription_without_generate_invoice_past_due(self):
subscription = create_subscription(
start_date="2018-01-01",
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
)
@@ -442,7 +448,7 @@ class TestSubscription(ERPNextTestSuite):
frappe.db.set_value("Customer", party, "default_currency", "USD")
subscription = create_subscription(
start_date="2018-01-01",
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
party=party,
)
@@ -464,7 +470,7 @@ class TestSubscription(ERPNextTestSuite):
frappe.db.set_value("Customer", party, "default_currency", "USD")
subscription = create_subscription(
start_date="2018-01-01",
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
party=party,
)
@@ -517,7 +523,7 @@ class TestSubscription(ERPNextTestSuite):
subscription = create_subscription(
start_date="2023-01-01",
end_date="2023-02-28",
generate_invoice_at="Days before the current subscription period",
generate_invoice_at="Bill N days before period start",
number_of_days=10,
generate_new_invoices_past_due_date=1,
)
@@ -555,7 +561,7 @@ class TestSubscription(ERPNextTestSuite):
start_date=start_date,
party_type="Supplier",
party="_Test Supplier",
generate_invoice_at="Days before the current subscription period",
generate_invoice_at="Bill N days before period start",
generate_new_invoices_past_due_date=1,
number_of_days=2,
plans=[{"plan": "_Test Plan Name 5", "qty": 1}],
@@ -577,7 +583,7 @@ class TestSubscription(ERPNextTestSuite):
end_date=add_days(start_date, 8),
cancel_at_period_end=1,
generate_new_invoices_past_due_date=1,
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
plans=[{"plan": "_Test plan name 10", "qty": 1}],
)
# Catch-up billing on creation generates every elapsed period and cancels at end
@@ -598,7 +604,7 @@ class TestSubscription(ERPNextTestSuite):
end_date=add_days(start_date, 6),
cancel_at_period_end=1,
generate_new_invoices_past_due_date=1,
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
plans=[{"plan": "_Test plan name 10", "qty": 1}],
)
@@ -684,7 +690,7 @@ class TestSubscription(ERPNextTestSuite):
end_date=end_date,
party_type="Customer",
party="_Test Customer",
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
generate_new_invoices_past_due_date=1,
plans=[{"plan": "_Test Plan 3 Day", "qty": 1}],
)
@@ -713,7 +719,7 @@ class TestSubscription(ERPNextTestSuite):
def test_status_updates_immediately_when_invoice_paid(self):
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
)
subscription.process(posting_date=nowdate())
@@ -729,7 +735,7 @@ class TestSubscription(ERPNextTestSuite):
def test_invoice_update_hook_refreshes_subscription_status(self):
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
)
subscription.process(posting_date=nowdate())
@@ -748,7 +754,7 @@ class TestSubscription(ERPNextTestSuite):
# Test that payment entry → invoice → subscription status update chain works
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
)
subscription.process(posting_date=nowdate())
@@ -771,16 +777,33 @@ class TestSubscription(ERPNextTestSuite):
def test_first_invoice_generated_on_create_for_prepaid(self):
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
)
self.assertEqual(len(subscription.invoices), 1)
def test_current_invoice_dates_reflect_latest_invoice(self):
subscription = create_subscription(
start_date="2018-01-01",
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
)
subscription.process(posting_date="2018-01-01")
invoice = subscription.get_current_invoice()
subscription.reload()
self.assertEqual(getdate(subscription.current_invoice_start), getdate(invoice.from_date))
self.assertEqual(getdate(subscription.current_invoice_end), getdate(invoice.to_date))
# `next_billing_period_start` tracks the next (unbilled) period.
self.assertEqual(
getdate(subscription.next_billing_period_start), getdate(add_days(invoice.to_date, 1))
)
def test_first_invoice_not_generated_on_create_during_trial(self):
subscription = create_subscription(
start_date=nowdate(),
trial_period_start=nowdate(),
trial_period_end=add_days(nowdate(), 30),
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
)
self.assertEqual(len(subscription.invoices), 0)
self.assertEqual(subscription.status, "Trialing")
@@ -790,7 +813,7 @@ class TestSubscription(ERPNextTestSuite):
try:
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
)
self.assertEqual(len(subscription.invoices), 0)
finally:
@@ -799,10 +822,144 @@ class TestSubscription(ERPNextTestSuite):
def test_first_invoice_not_generated_for_future_dated_subscription(self):
subscription = create_subscription(
start_date=add_days(nowdate(), 10),
generate_invoice_at="Beginning of the current subscription period",
generate_invoice_at="Prepaid (bill at period start)",
)
self.assertEqual(len(subscription.invoices), 0)
def test_generate_invoice_at_migration_patch(self):
from erpnext.patches.v16_0.migrate_subscription_generate_invoice_at import VALUE_MAP, execute
subscription = create_subscription(start_date=add_days(nowdate(), 10))
for old_value, new_value in VALUE_MAP.items():
frappe.db.set_value("Subscription", subscription.name, "generate_invoice_at", old_value)
execute()
self.assertEqual(
frappe.db.get_value("Subscription", subscription.name, "generate_invoice_at"), new_value
)
def test_next_billing_period_populated_for_prepaid(self):
subscription = create_subscription(
start_date=add_days(nowdate(), 10),
generate_invoice_at="Prepaid (bill at period start)",
)
self.assertEqual(getdate(subscription.next_billing_period_start), getdate(add_days(nowdate(), 10)))
self.assertGreater(
getdate(subscription.next_billing_period_end), getdate(subscription.next_billing_period_start)
)
def test_status_becomes_refunded_when_only_invoice_credited(self):
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
)
subscription.process(posting_date=nowdate())
self.assertEqual(subscription.status, "Unpaid")
make_full_credit_note(subscription.get_current_invoice().name)
subscription.reload()
self.assertEqual(subscription.status, "Refunded")
def test_status_stays_unpaid_when_one_of_two_invoices_credited(self):
subscription = create_subscription(
start_date=add_months(nowdate(), -2),
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
generate_new_invoices_past_due_date=1,
)
invoices = frappe.get_all(
"Sales Invoice",
filters={"subscription": subscription.name, "docstatus": 1, "is_return": 0},
pluck="name",
order_by="from_date asc",
)
self.assertGreaterEqual(len(invoices), 2)
make_full_credit_note(invoices[0])
subscription.reload()
self.assertNotEqual(subscription.status, "Refunded")
def test_refunded_reverts_to_active_after_full_settlement(self):
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
)
subscription.process(posting_date=nowdate())
invoice = subscription.get_current_invoice()
make_full_credit_note(invoice.name)
subscription.reload()
self.assertEqual(subscription.status, "Refunded")
invoice.db_set("status", "Paid")
invoice.db_set("outstanding_amount", 0)
subscription.process()
self.assertEqual(subscription.status, "Active")
def test_heatmap_spans_twelve_months_from_start_month(self):
start_date = getdate("2024-03-14")
subscription = create_subscription(start_date=start_date)
heatmap = subscription.get_billing_heatmap()
self.assertEqual(getdate(heatmap[0]["date"]), get_first_day(start_date))
self.assertEqual(
getdate(heatmap[-1]["date"]), get_last_day(add_months(get_first_day(start_date), 11))
)
self.assertIn("status", heatmap[0])
def test_heatmap_marks_paid_days_green(self):
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
)
subscription.process(posting_date=nowdate())
invoice = subscription.get_current_invoice()
invoice.db_set("status", "Paid")
invoice.db_set("outstanding_amount", 0)
subscription.reload()
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "paid")
def test_heatmap_marks_future_planned_days(self):
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Prepaid (bill at period start)",
)
today = getdate(nowdate())
planned = [
cell
for cell in subscription.get_billing_heatmap()
if cell["status"] == "planned" and getdate(cell["date"]) > today
]
self.assertTrue(planned)
def test_heatmap_marks_refunded_days_for_credited_periods(self):
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Prepaid (bill at period start)",
submit_invoice=1,
)
subscription.process(posting_date=nowdate())
invoice = subscription.get_current_invoice()
make_full_credit_note(invoice.name)
subscription.reload()
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "refunded")
def make_full_credit_note(invoice_name):
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
credit_note = make_sales_return(invoice_name)
credit_note.insert()
credit_note.submit()
return credit_note
def make_plans():
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")

View File

@@ -509,11 +509,6 @@ def get_party_advance_account(party_type, party, company):
return account
@frappe.whitelist()
def get_party_bank_account(party_type: str, party: str):
return frappe.db.get_value("Bank Account", {"party_type": party_type, "party": party, "is_default": 1})
def get_party_account_currency(party_type, party, company):
def generator():
party_account = get_party_account(party_type, party, company)

View File

@@ -927,8 +927,28 @@ class ReceivablePayableReport:
if self.filters.project:
self.qb_selection_filter.append(self.ple.project.isin(self.filters.project))
self.add_user_permission_filters()
self.add_accounting_dimensions_filters()
def add_user_permission_filters(self):
# Party is a dynamic link, so match conditions cannot auto-apply Customer/Supplier user permissions
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
from frappe.permissions import get_allowed_docs_for_doctype
user_permissions = get_user_permissions()
if not user_permissions:
return
for party_type in self.party_type:
if party_type not in user_permissions:
continue
allowed_parties = get_allowed_docs_for_doctype(user_permissions[party_type], party_type)
self.qb_selection_filter.append(
(self.ple.party_type != party_type) | self.ple.party.isin(allowed_parties or [""])
)
def get_cost_center_conditions(self):
cost_center_list = get_cost_centers_with_children(self.filters.cost_center)
self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list))

View File

@@ -1243,3 +1243,44 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
self.assertEqual(len(report[1]), 1)
row = report[1][0]
self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding])
def test_accounts_receivable_respects_user_permissions(self):
# Party is a dynamic link on Payment Ledger Entry, so user permissions on Customer
# must be applied explicitly. The report should only show permitted customers.
original_customer = self.customer
second_customer = "_Test AR Perm Customer"
# create_customer overrides self.customer, so build the restricted invoice first
self.create_customer(customer_name=second_customer)
self.create_sales_invoice(no_payment_schedule=True)
self.customer = original_customer
allowed_invoice = self.create_sales_invoice(no_payment_schedule=True)
test_user = "test_ar_user_permission@example.com"
if not frappe.db.exists("User", test_user):
user = frappe.new_doc("User")
user.email = test_user
user.first_name = "AR Perm"
user.append("roles", {"role": "Accounts User"})
user.save()
frappe.permissions.add_user_permission("Customer", original_customer, test_user)
filters = {
"company": self.company,
"party_type": "Customer",
"report_date": today(),
"range": "30, 60, 90, 120",
}
frappe.set_user(test_user)
try:
report = execute(filters)
finally:
frappe.set_user("Administrator")
parties = {row.party for row in report[1]}
self.assertIn(original_customer, parties)
self.assertNotIn(second_customer, parties)
self.assertEqual(allowed_invoice.customer, original_customer)

View File

@@ -4,6 +4,7 @@
import frappe
from frappe import _
from frappe.query_builder import CustomFunction
from frappe.utils import cint
@@ -97,19 +98,32 @@ def get_sales_details(filters):
if filters["based_on"] not in ("Sales Order", "Sales Invoice"):
frappe.throw(_("Invalid value {0} for 'Based On'").format(filters["based_on"]))
date_field = "s.transaction_date" if filters["based_on"] == "Sales Order" else "s.posting_date"
parent = frappe.qb.DocType(filters["based_on"])
child_doctype = "Sales Order Item" if filters["based_on"] == "Sales Order" else "Sales Invoice Item"
child = frappe.qb.DocType(child_doctype)
sales_data = frappe.db.sql(
"""
select s.territory, s.customer, si.item_group, si.item_code, si.qty, {date_field} as last_order_date,
DATEDIFF(CURRENT_DATE, {date_field}) as days_since_last_order
from `tab{doctype}` s, `tab{doctype} Item` si
where s.name = si.parent and s.docstatus = 1
order by days_since_last_order """.format( # nosec
date_field=date_field, doctype=filters["based_on"]
),
as_dict=1,
)
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
current_date = CustomFunction("CURRENT_DATE", [])
date_col = parent.transaction_date if filters["based_on"] == "Sales Order" else parent.posting_date
days_since_last_order = date_diff(current_date(), date_col)
sales_data = (
frappe.qb.from_(parent)
.inner_join(child)
.on(parent.name == child.parent)
.select(
parent.territory,
parent.customer,
child.item_group,
child.item_code,
child.qty,
date_col.as_("last_order_date"),
days_since_last_order.as_("days_since_last_order"),
)
.where(parent.docstatus == 1)
.orderby(days_since_last_order)
).run(as_dict=True)
for d in sales_data:
item_details_map.setdefault((d.territory, d.item_code), d)

View File

@@ -5,7 +5,7 @@
import frappe
from frappe import _
from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied
from frappe.model.workflow import get_workflow_name
from frappe.utils import flt, get_link_to_form, getdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions
@@ -222,15 +222,12 @@ class ChildItemUpdater:
current_state = self.parent.get(workflow_doc.workflow_state_field)
roles = frappe.get_roles()
transitions = [
t.as_dict()
for t in workflow_doc.transitions
if t.next_state == current_state
and t.allowed in roles
and is_transition_condition_satisfied(t, self.parent)
]
allowed = any(
state.state == current_state and (not state.allow_edit or state.allow_edit in roles)
for state in workflow_doc.states
)
if not transitions:
if not allowed:
frappe.throw(
_("You are not allowed to update as per the conditions set in {} Workflow.").format(
get_link_to_form("Workflow", workflow)

View File

@@ -80,6 +80,8 @@ class TestUtils(ERPNextTestSuite):
purchase_invoice.submit()
payment_entry = get_payment_entry(purchase_invoice.doctype, purchase_invoice.name)
payment_entry.target_exchange_rate = 82.32
payment_entry.set_amounts()
payment_entry.paid_amount = 15725
payment_entry.deductions = []
payment_entry.save()

View File

@@ -304,6 +304,7 @@ def get_balance_on(
)
if party_type and party:
frappe.has_permission(party_type, "read", party, throw=True)
cond.append(
f"""gle.party_type = {frappe.db.escape(party_type)} and gle.party = {frappe.db.escape(party)} """
)
@@ -446,15 +447,13 @@ def add_ac(args: frappe._dict | None = None):
if not args:
args = frappe.local.form_dict
args.pop("ignore_permissions", None)
frappe.has_permission("Account", "create", throw=True)
args.doctype = "Account"
args = make_tree_args(**args)
ac = frappe.new_doc("Account")
if args.get("ignore_permissions"):
ac.flags.ignore_permissions = True
args.pop("ignore_permissions")
ac.update(args)
if not ac.parent_account:
@@ -1554,6 +1553,7 @@ def update_cost_center(
Renames the document by adding the number as a prefix to the current name and updates
all transaction where it was present.
"""
frappe.has_permission("Cost Center", "write", doc=docname, throw=True)
validate_field_number("Cost Center", docname, cost_center_number, company, "cost_center_number")
if cost_center_number:

View File

@@ -24,7 +24,7 @@ import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_checks_for_pl_and_bs_accounts,
)
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
get_asset_depr_schedule_doc,

View File

@@ -86,7 +86,7 @@ class SubcontractingService:
def update_subcontracting_order_status(self) -> None:
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
update_subcontracting_order_status as update_sco_status,
set_subcontracting_order_status as update_sco_status,
)
doc = self.doc

View File

@@ -845,8 +845,10 @@
"read_only": 1
},
{
"description": "Product Bundle version this row was packed from",
"fieldname": "product_bundle",
"fieldtype": "Link",
"hidden": 1,
"label": "Product Bundle",
"options": "Product Bundle",
"read_only": 1
@@ -939,7 +941,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-05-20 00:50:16.192936",
"modified": "2026-06-08 21:00:00.000000",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",

View File

@@ -180,6 +180,7 @@ def refresh_scorecards():
def make_all_scorecards(docname: str):
sc = frappe.get_doc("Supplier Scorecard", docname)
supplier = frappe.get_doc("Supplier", sc.supplier)
supplier.check_permission("write")
start_date = getdate(supplier.creation)
end_date = get_scorecard_date(sc.period, start_date)

View File

@@ -296,6 +296,7 @@ def get_message():
@frappe.whitelist()
def set_default_supplier(item_code: str, supplier: str, company: str):
frappe.has_permission("Item", "write", doc=item_code, throw=True)
frappe.db.set_value(
"Item Default",
{"parent": item_code, "company": company},

View File

@@ -38,7 +38,7 @@ from erpnext.accounts.party import (
from erpnext.accounts.utils import (
get_advance_payment_doctypes as _get_advance_payment_doctypes,
)
from erpnext.accounts.utils import validate_fiscal_year
from erpnext.accounts.utils import get_fiscal_year, validate_fiscal_year
from erpnext.controllers.print_settings import (
set_print_templates_for_item_table,
set_print_templates_for_taxes,
@@ -640,21 +640,29 @@ class AccountsController(TransactionBase):
self.calculate_contribution()
def validate_date_with_fiscal_year(self):
if self.meta.get_field("fiscal_year"):
date_field = None
if self.meta.get_field("posting_date"):
date_field = "posting_date"
elif self.meta.get_field("transaction_date"):
date_field = "transaction_date"
date_field = None
if self.meta.get_field("posting_date"):
date_field = "posting_date"
elif self.meta.get_field("transaction_date"):
date_field = "transaction_date"
if date_field and self.get(date_field):
validate_fiscal_year(
self.get(date_field),
self.fiscal_year,
self.company,
self.meta.get_label(date_field),
self,
)
if not date_field or not self.get(date_field):
return
if self.meta.get_field("fiscal_year"):
validate_fiscal_year(
self.get(date_field),
self.fiscal_year,
self.company,
self.meta.get_label(date_field),
self,
)
else:
get_fiscal_year(
self.get(date_field),
company=self.company,
label=self.meta.get_label(date_field),
)
def validate_due_date(self):
if self.get("is_pos") or self.doctype not in ["Sales Invoice", "Purchase Invoice"]:

View File

@@ -598,6 +598,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
target_doc.so_detail = source_doc.so_detail
target_doc.expense_account = source_doc.expense_account
target_doc.dn_detail = source_doc.name
target_doc.cost_center = source_doc.cost_center
if default_warehouse_for_sales_return:
target_doc.warehouse = default_warehouse_for_sales_return
elif doctype == "Sales Invoice" or doctype == "POS Invoice":

View File

@@ -425,7 +425,12 @@ class SellingController(StockController):
row.new_item_code
for row in frappe.get_all(
"Product Bundle",
filters={"new_item_code": ("in", items_to_fetch), "disabled": 0},
filters={
"new_item_code": ("in", items_to_fetch),
"is_active": 1,
"docstatus": 1,
"disabled": 0,
},
fields="new_item_code",
)
}
@@ -979,9 +984,14 @@ class SellingController(StockController):
qty_can_be_deliver = 0
if sre_doc.reservation_based_on == "Serial and Batch":
sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
# Delivered serial/batch may live in a Serial and Batch Bundle or directly in the
# row's serial_no/batch_no fields (use_serial_batch_fields). Read from whichever is
# present so this never crashes on a missing bundle.
(
delivered_serial_nos,
delivered_batch_qty,
) = get_delivered_serial_batch_for_reservation(item)
if sre_doc.has_serial_no:
delivered_serial_nos = [d.serial_no for d in sbb.entries]
for entry in sre_doc.sb_entries:
if entry.serial_no in delivered_serial_nos:
entry.delivered_qty = 1 # Qty will always be 0 or 1 for Serial No.
@@ -989,16 +999,16 @@ class SellingController(StockController):
qty_can_be_deliver += 1
delivered_serial_nos.remove(entry.serial_no)
else:
delivered_batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries}
for entry in sre_doc.sb_entries:
if entry.batch_no in delivered_batch_qty:
available_batch_qty = delivered_batch_qty.get(entry.batch_no, 0)
if available_batch_qty > 0:
delivered_qty = min(
(entry.qty - entry.delivered_qty), delivered_batch_qty[entry.batch_no]
(entry.qty - entry.delivered_qty), available_batch_qty
)
entry.delivered_qty += delivered_qty
entry.db_update()
qty_can_be_deliver += delivered_qty
delivered_batch_qty[entry.batch_no] -= delivered_qty
delivered_batch_qty[entry.batch_no] = available_batch_qty - delivered_qty
else:
# `Delivered Qty` should be less than or equal to `Reserved Qty`.
qty_can_be_deliver = min(
@@ -1174,3 +1184,31 @@ def get_serial_and_batch_bundle(child, parent, delivery_note_child=None):
child.db_set("serial_and_batch_bundle", doc.name)
return doc.name
def get_delivered_serial_batch_for_reservation(item):
"""Serial nos and per-batch qty delivered by a stock row.
The detail may be stored in a Serial and Batch Bundle or directly in the row's
``serial_no``/``batch_no`` fields (``use_serial_batch_fields``). Reading from whichever is
present keeps the Stock Reservation Entry delivered-qty update independent of a bundle being
created -- delivering reserved serial/batch stock used to crash when the row had no bundle.
"""
serial_nos, batch_qty = [], {}
if item.get("serial_and_batch_bundle"):
bundle = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
for row in bundle.entries:
if row.serial_no:
serial_nos.append(row.serial_no)
if row.batch_no:
batch_qty[row.batch_no] = batch_qty.get(row.batch_no, 0) + abs(flt(row.qty))
else:
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
if item.get("serial_no"):
serial_nos = get_serial_nos(item.serial_no)
if item.get("batch_no"):
batch_qty[item.batch_no] = abs(flt(item.get("stock_qty") or item.get("qty")))
return serial_nos, batch_qty

View File

@@ -1124,10 +1124,10 @@ class SubcontractingInwardController:
def update_inward_order_status(self):
if self.subcontracting_inward_order:
from erpnext.subcontracting.doctype.subcontracting_inward_order.subcontracting_inward_order import (
update_subcontracting_inward_order_status,
set_subcontracting_inward_order_status,
)
update_subcontracting_inward_order_status(self.subcontracting_inward_order)
set_subcontracting_inward_order_status(self.subcontracting_inward_order)
@frappe.whitelist()

View File

@@ -32,7 +32,7 @@ class calculate_taxes_and_totals:
def __init__(self, doc: Document):
self.doc = doc
frappe.flags.round_off_applicable_accounts = (
get_round_off_applicable_accounts(self.doc.company, []) or []
get_round_off_applicable_accounts(self.doc.company, [], self.doc) or []
)
frappe.flags.round_row_wise_tax = frappe.get_single_value("Accounts Settings", "round_row_wise_tax")
@@ -1240,14 +1240,16 @@ def get_itemised_tax_breakup_html(doc):
@frappe.whitelist()
def get_round_off_applicable_accounts(company: str, account_list: list | str):
def get_round_off_applicable_accounts(
company: str, account_list: list | str, doc: str | dict | Document | None = None
):
# required to set correct region
with temporary_flag("company", company):
return get_regional_round_off_accounts(company, account_list)
return get_regional_round_off_accounts(company, account_list, doc)
@erpnext.allow_regional
def get_regional_round_off_accounts(company, account_list):
def get_regional_round_off_accounts(company, account_list, doc=None):
pass

View File

@@ -988,6 +988,34 @@ class TestAccountsController(ERPNextTestSuite):
self.assertEqual(sinv.taxes[0].account_head, "_Test Account Excise Duty - _TC")
self.assertEqual(sinv.total_taxes_and_charges, 5)
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{"add_taxes_from_item_tax_template": 1, "add_taxes_from_taxes_and_charges_template": 0},
)
def test_19b_fetch_taxes_from_item_tax_template_purchase_invoice(self):
pinv = frappe.new_doc("Purchase Invoice")
pinv.supplier = "_Test Supplier"
pinv.company = self.company
pinv.currency = "INR"
item = pinv.append(
"items",
{
"item_code": "_Test Item",
"qty": 1,
"rate": 50,
"item_tax_template": "_Test Account Excise Duty @ 10 - _TC",
},
)
item_details = pinv.fetch_item_details(item)
pinv.add_taxes_from_item_template(item, item_details)
self.assertEqual(len(pinv.taxes), 1)
tax_row = pinv.taxes[0]
self.assertEqual(tax_row.account_head, "_Test Account Excise Duty - _TC")
self.assertEqual(tax_row.category, "Total")
self.assertEqual(tax_row.add_deduct_tax, "Add")
def test_20_journal_against_sales_invoice(self):
# Invoice in Foreign Currency
si = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1)

View File

@@ -16,12 +16,13 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestItemWiseInventoryAccount(ERPNextTestSuite):
def setUp(self):
self.company = make_company()
self.company_abbr = frappe.db.get_value("Company", self.company, "abbr")
self.company = "_Test Company with perpetual inventory"
self.company_abbr = "TCP1"
self.default_warehouse = frappe.db.get_value(
"Warehouse",
{"company": self.company, "is_group": 0, "warehouse_name": ("like", "%Stores%")},
)
frappe.db.set_value("Company", self.company, "enable_item_wise_inventory_account", 1)
def test_item_account_for_purchase_receipt_entry(self):
items = {
@@ -577,23 +578,3 @@ class TestItemWiseInventoryAccount(ERPNextTestSuite):
gl_value = gl_value * -1
self.assertEqual(sle_value, gl_value, f"GL Entry not created for {item_code} correctly")
def make_company():
company = "_Test Company for Item Wise Inventory Account"
if frappe.db.exists("Company", company):
return company
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": "_Test Company for Item Wise Inventory Account",
"abbr": "_TCIWIA",
"default_currency": "INR",
"country": "India",
"enable_perpetual_inventory": 1,
"enable_item_wise_inventory_account": 1,
}
).insert()
return company.name

View File

@@ -15,7 +15,7 @@ class TestTaxesAndTotals(ERPNextTestSuite):
"""
test_account = "_Test Round Off Account"
def mock_regional(company, account_list: list) -> list:
def mock_regional(company, account_list: list, doc=None) -> list:
# Simulates a regional override
account_list.extend([test_account])
return account_list

View File

@@ -14,6 +14,7 @@
"opportunity_section",
"close_opportunity_after_days",
"column_break_9",
"enable_opportunity_creation_from_contact_us",
"quotation_section",
"default_valid_till",
"section_break_13",
@@ -98,15 +99,20 @@
"fieldname": "update_timestamp_on_new_communication",
"fieldtype": "Check",
"label": "Update timestamp on new communication"
},
{
"default": "0",
"fieldname": "enable_opportunity_creation_from_contact_us",
"fieldtype": "Check",
"label": "Enable Opportunity Creation from Contact Us"
}
],
"grid_page_length": 50,
"hide_toolbar": 0,
"icon": "fa fa-cog",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-16 13:28:19.573964",
"modified": "2026-06-11 23:09:49.750381",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM Settings",

View File

@@ -2,6 +2,7 @@
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
@@ -20,8 +21,20 @@ class CRMSettings(Document):
carry_forward_communication_and_comments: DF.Check
close_opportunity_after_days: DF.Int
default_valid_till: DF.Data | None
enable_opportunity_creation_from_contact_us: DF.Check
update_timestamp_on_new_communication: DF.Check
# end: auto-generated types
def validate(self):
frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", ""))
self.validate_enable_opportunity_creation_from_contact_us()
def validate_enable_opportunity_creation_from_contact_us(self):
contact_disabled = frappe.get_single_value("Contact Us Settings", "is_disabled")
if self.enable_opportunity_creation_from_contact_us and contact_disabled:
frappe.throw(
_(
"Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled."
)
)

View File

@@ -8,7 +8,7 @@ from frappe.contacts.address_and_contact import (
load_address_and_contact,
)
from frappe.model.document import Document
from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address
from frappe.utils import comma_and, get_link_to_form, validate_email_address
from frappe.utils.data import DateTimeLikeObject
from erpnext.accounts.party import set_taxes
@@ -173,9 +173,6 @@ class Lead(SellingController, CRMNote):
if self.email_id == self.lead_owner:
frappe.throw(_("Lead Owner cannot be same as the Lead Email Address"))
if self.is_new() or not self.image:
self.image = has_gravatar(self.email_id)
def link_to_contact(self):
# update contact links
if self.contact_doc:
@@ -386,7 +383,7 @@ def get_lead_with_phone_number(number):
def add_lead_to_prospect(lead: str, prospect: str):
prospect = frappe.get_doc("Prospect", prospect)
prospect.append("leads", {"lead": lead})
prospect.save(ignore_permissions=True)
prospect.save()
carry_forward_communication_and_comments = frappe.db.get_single_value(
"CRM Settings", "carry_forward_communication_and_comments"

View File

@@ -130,7 +130,6 @@ def make_lead_from_communication(communication: str, ignore_communication_links:
}
)
lead.flags.ignore_mandatory = True
lead.flags.ignore_permissions = True
lead.insert()
lead_name = lead.name

View File

@@ -145,7 +145,7 @@ def make_opportunity_from_communication(
"opportunity_from": opportunity_from,
"party_name": lead,
}
).insert(ignore_permissions=True)
).insert()
link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links)

View File

@@ -31,20 +31,16 @@ def create_custom_fields_for_frappe_crm():
@frappe.whitelist()
def create_prospect_against_crm_deal():
doc = frappe.form_dict
prospect = frappe.get_doc(
{
"doctype": "Prospect",
"company_name": doc.organization or doc.lead_name,
"no_of_employees": doc.no_of_employees,
"prospect_owner": doc.deal_owner,
"company": doc.erpnext_company,
"crm_deal": doc.crm_deal,
"territory": doc.territory,
"industry": doc.industry,
"website": doc.website,
"annual_revenue": doc.annual_revenue,
}
)
prospect = frappe.new_doc("Prospect")
prospect.company_name = doc.organization or doc.lead_name
prospect.no_of_employees = doc.no_of_employees
prospect.prospect_owner = doc.deal_owner
prospect.company = doc.erpnext_company
prospect.crm_deal = doc.crm_deal
prospect.territory = doc.territory
prospect.industry = doc.industry
prospect.website = doc.website
prospect.annual_revenue = doc.annual_revenue
try:
prospect_name = frappe.db.get_value("Prospect", {"company_name": prospect.company_name})
@@ -150,6 +146,18 @@ def contact_exists(email, mobile_no):
return False
CUSTOMER_ALLOWED_FIELDS = {
"customer_name",
"customer_group",
"customer_type",
"territory",
"default_currency",
"industry",
"website",
"crm_deal",
}
@frappe.whitelist()
def create_customer(customer_data: dict | None = None):
if not customer_data:
@@ -158,9 +166,11 @@ def create_customer(customer_data: dict | None = None):
try:
customer_name = frappe.db.exists("Customer", {"customer_name": customer_data.get("customer_name")})
if not customer_name:
customer = frappe.get_doc({"doctype": "Customer", **customer_data}).insert(
ignore_permissions=True
)
customer = frappe.new_doc("Customer")
for field in CUSTOMER_ALLOWED_FIELDS:
if customer_data.get(field) is not None:
customer.set(field, customer_data.get(field))
customer.insert(ignore_permissions=True)
customer_name = customer.name
contacts = json.loads(customer_data.get("contacts"))

View File

@@ -5,6 +5,11 @@ from frappe.utils import cstr, now, today
from pypika import functions
def disable_opportunity_creation_on_contact_us_disabled(doc, method):
if doc.is_disabled:
frappe.db.set_single_value("CRM Settings", "enable_opportunity_creation_from_contact_us", 0)
def update_lead_phone_numbers(contact, method):
if contact.phone_nos:
contact_lead = contact.get_link_for("Lead")

View File

@@ -383,6 +383,9 @@ doc_events = {
"Event": {
"after_insert": "erpnext.crm.utils.link_events_with_prospect",
},
"Contact Us Settings": {
"on_update": "erpnext.crm.utils.disable_opportunity_creation_on_contact_us_disabled",
},
"Sales Invoice": {
"on_submit": [
"erpnext.regional.italy.utils.sales_invoice_on_submit",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

62782
erpnext/locale/hi.po Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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