feat: Expand valid account types for customer income and expense accounts to include receivable, payable, and direct cost types, and update Odoo version in manifest.

This commit is contained in:
Suherdy Yacob 2026-02-25 16:19:42 +07:00
parent 5ddb93e2ec
commit 64ed12822e
5 changed files with 41 additions and 21 deletions

View File

@ -5,7 +5,7 @@
'summary': "Customer-specific income and expense accounts for sales transactions", 'summary': "Customer-specific income and expense accounts for sales transactions",
'description': """ 'description': """
This module extends Odoo 18's accounting functionality to support customer-specific This module extends Odoo 19's accounting functionality to support customer-specific
income and expense accounts for sales transactions. By default, Odoo creates journal income and expense accounts for sales transactions. By default, Odoo creates journal
entries using the income and expense accounts defined in the product category. This entries using the income and expense accounts defined in the product category. This
module allows defining income and expense accounts at the customer level, which take module allows defining income and expense accounts at the customer level, which take

View File

@ -102,5 +102,3 @@ class AccountMoveLine(models.Model):
"Using standard product category account.", "Using standard product category account.",
partner.name, partner.id, line.move_id.name or 'draft', str(e) partner.name, partner.id, line.move_id.name or 'draft', str(e)
) )

View File

@ -15,7 +15,7 @@ class ResPartner(models.Model):
'account.account', 'account.account',
company_dependent=True, company_dependent=True,
string='Customer Income Account', string='Customer Income Account',
domain="[('account_type', '=', 'income'), ('active', '=', True)]", domain="[('account_type', 'in', ('asset_receivable', 'liability_payable', 'income', 'income_other', 'expense', 'expense_direct_cost')), ('active', '=', True)]",
help="This account will be used for revenue entries when selling to this customer. " help="This account will be used for revenue entries when selling to this customer. "
"If not set, the income account from the product category will be used." "If not set, the income account from the product category will be used."
) )
@ -24,11 +24,13 @@ class ResPartner(models.Model):
'account.account', 'account.account',
company_dependent=True, company_dependent=True,
string='Customer Expense Account (COGS)', string='Customer Expense Account (COGS)',
domain="[('account_type', '=', 'expense'), ('active', '=', True)]", domain="[('account_type', 'in', ('asset_receivable', 'liability_payable', 'income', 'income_other', 'expense', 'expense_direct_cost')), ('active', '=', True)]",
help="This account will be used for COGS entries when selling to this customer. " help="This account will be used for COGS entries when selling to this customer. "
"If not set, the expense account from the product category will be used." "If not set, the expense account from the product category will be used."
) )
def _get_customer_income_account(self): def _get_customer_income_account(self):
""" """
Returns the customer-specific income account if defined. Returns the customer-specific income account if defined.
@ -144,16 +146,17 @@ class ResPartner(models.Model):
@api.constrains('property_account_income_customer_id') @api.constrains('property_account_income_customer_id')
def _check_income_account(self): def _check_income_account(self):
""" """
Validate that the customer income account is of type 'income', Validate that the customer income account is of a valid type,
not deprecated, and belongs to the correct company. not deprecated, and belongs to the correct company.
""" """
valid_types = ('asset_receivable', 'liability_payable', 'income', 'income_other', 'expense', 'expense_direct_cost')
for partner in self: for partner in self:
account = partner.property_account_income_customer_id account = partner.property_account_income_customer_id
if account: if account:
# Check account type # Check account type
if account.account_type != 'income': if account.account_type not in valid_types:
raise ValidationError(_( raise ValidationError(_(
"The selected income account '%s' must be of type 'Income'. " "The selected income account '%s' must be of type 'Income', 'Receivable', 'Payable', or 'Cost of Revenue'. "
"Please select a valid income account." "Please select a valid income account."
) % account.display_name) ) % account.display_name)
@ -175,16 +178,17 @@ class ResPartner(models.Model):
@api.constrains('property_account_expense_customer_id') @api.constrains('property_account_expense_customer_id')
def _check_expense_account(self): def _check_expense_account(self):
""" """
Validate that the customer expense account is of type 'expense', Validate that the customer expense account is of a valid type,
not deprecated, and belongs to the correct company. not deprecated, and belongs to the correct company.
""" """
valid_types = ('asset_receivable', 'liability_payable', 'income', 'income_other', 'expense', 'expense_direct_cost')
for partner in self: for partner in self:
account = partner.property_account_expense_customer_id account = partner.property_account_expense_customer_id
if account: if account:
# Check account type # Check account type
if account.account_type != 'expense': if account.account_type not in valid_types:
raise ValidationError(_( raise ValidationError(_(
"The selected expense account '%s' must be of type 'Expense'. " "The selected expense account '%s' must be of type 'Expense', 'Cost of Revenue', 'Receivable', or 'Payable'. "
"Please select a valid expense account." "Please select a valid expense account."
) % account.display_name) ) % account.display_name)

View File

@ -82,7 +82,7 @@ class TestAccountValidation(TransactionCase):
@settings(max_examples=100) @settings(max_examples=100)
@given( @given(
customer_name=st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_categories=('Cs', 'Cc'))), customer_name=st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_categories=('Cs', 'Cc'))),
account_type=st.sampled_from(['asset_current', 'liability_current', 'equity', 'expense']), account_type=st.sampled_from(['asset_current', 'liability_current', 'equity']),
) )
def test_property_10_income_account_type_validation(self, customer_name, account_type): def test_property_10_income_account_type_validation(self, customer_name, account_type):
""" """
@ -98,7 +98,6 @@ class TestAccountValidation(TransactionCase):
'asset_current': self.asset_account, 'asset_current': self.asset_account,
'liability_current': self.liability_account, 'liability_current': self.liability_account,
'equity': self.equity_account, 'equity': self.equity_account,
'expense': self.expense_account,
} }
wrong_account = account_map[account_type] wrong_account = account_map[account_type]
@ -122,7 +121,7 @@ class TestAccountValidation(TransactionCase):
@settings(max_examples=100) @settings(max_examples=100)
@given( @given(
customer_name=st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_categories=('Cs', 'Cc'))), customer_name=st.text(min_size=1, max_size=50, alphabet=st.characters(blacklist_categories=('Cs', 'Cc'))),
account_type=st.sampled_from(['asset_current', 'liability_current', 'equity', 'income']), account_type=st.sampled_from(['asset_current', 'liability_current', 'equity']),
) )
def test_property_11_expense_account_type_validation(self, customer_name, account_type): def test_property_11_expense_account_type_validation(self, customer_name, account_type):
""" """
@ -138,7 +137,6 @@ class TestAccountValidation(TransactionCase):
'asset_current': self.asset_account, 'asset_current': self.asset_account,
'liability_current': self.liability_account, 'liability_current': self.liability_account,
'equity': self.equity_account, 'equity': self.equity_account,
'income': self.income_account,
} }
wrong_account = account_map[account_type] wrong_account = account_map[account_type]

View File

@ -124,12 +124,22 @@ class TestCustomerFormView(TransactionCase):
self.assertIn( self.assertIn(
'income', 'income',
domain_str, domain_str,
"Income account field domain should filter for income type accounts" "Income account field domain should include income type accounts"
) )
self.assertIn( self.assertIn(
'deprecated', 'asset_receivable',
domain_str, domain_str,
"Income account field domain should filter out deprecated accounts" "Income account field domain should include receivable type accounts"
)
self.assertIn(
'expense_direct_cost',
domain_str,
"Income account field domain should include cost of revenue type accounts"
)
self.assertIn(
'active',
domain_str,
"Income account field domain should filter out inactive accounts"
) )
def test_expense_account_field_domain(self): def test_expense_account_field_domain(self):
@ -160,12 +170,22 @@ class TestCustomerFormView(TransactionCase):
self.assertIn( self.assertIn(
'expense', 'expense',
domain_str, domain_str,
"Expense account field domain should filter for expense type accounts" "Expense account field domain should include expense type accounts"
) )
self.assertIn( self.assertIn(
'deprecated', 'liability_payable',
domain_str, domain_str,
"Expense account field domain should filter out deprecated accounts" "Expense account field domain should include payable type accounts"
)
self.assertIn(
'expense_direct_cost',
domain_str,
"Expense account field domain should include cost of revenue type accounts"
)
self.assertIn(
'active',
domain_str,
"Expense account field domain should filter out inactive accounts"
) )
def test_fields_are_optional(self): def test_fields_are_optional(self):