Loading exceptions.py +8 −0 Original line number Diff line number Diff line Loading @@ -22,3 +22,11 @@ class SequenceMissingError(ValidationError): class MemberUnpaidFeeError(ValidationError): pass class AmountLessThanZeroError(ValidationError): pass class PartyAccountPayableRequiredError(ValidationError): pass membership.py +35 −9 Original line number Diff line number Diff line # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. from decimal import Decimal from trytond.config import config from trytond.i18n import gettext Loading @@ -11,7 +10,8 @@ from trytond.model.exceptions import AccessError from trytond.transaction import Transaction from trytond.wizard import StateTransition, Wizard, StateView, Button from .exceptions import PeriodDateOverlapError from .exceptions import (PeriodDateOverlapError, AmountLessThanZeroError, PartyAccountPayableRequiredError) __all__ = [ 'Membership', 'Period', 'Fee', 'Line', 'PostFee', 'GenerateFee', Loading @@ -25,7 +25,10 @@ _STATES = {'readonly': Eval('state') != 'open'} _LINE_STATES = {'readonly': Eval('member_state', '') != 'draft'} _FEE_STATES = {'readonly': Bool(Eval('move'))} _FEE_STATES = { 'readonly': Bool(Eval('move')) and Eval('fee', Decimal('0.0')) == Decimal('0.0') } _MEMBER_STATES = [ ('draft', 'Draft'), Loading Loading @@ -167,6 +170,13 @@ class Period(ModelSQL, ModelView): super(Period, cls).validate(periods) for period in periods: period.check_dates() period.check_fee() def check_fee(self): if self.amount < Decimal('0.0'): raise AmountLessThanZeroError( gettext('association.msg_period_fee_invalid', period=self.rec_name)) def check_dates(self): transaction = Transaction() Loading Loading @@ -221,6 +231,13 @@ class Fee(ModelSQL, ModelView): "Move State"), 'on_change_with_move_state') paid = fields.Function(fields.Boolean('paid'), 'is_paid') reconciled = fields.Function(fields.Date('Reconciled'), 'get_reconciled') amount = fields.Function(fields.Numeric("Amount", fee_digit), 'on_change_with_amount') @fields.depends('period', '_parent_period.amount') def on_change_with_amount(self, name=None): if self.period: return self.period.amount @classmethod def __setup__(cls): Loading @@ -234,8 +251,10 @@ class Fee(ModelSQL, ModelView): cls._buttons.update({ 'post_move': { 'invisible': Eval('move_state') == 'posted', 'depends': ['move_state'], 'invisible': (Eval('move_state') == 'posted') | (Eval('amount', Decimal('0.0')) == Decimal('0.0')), 'depends': ['move_state', 'amount'], }, }) Loading @@ -247,7 +266,7 @@ class Fee(ModelSQL, ModelView): @classmethod def delete(cls, fees): for fee in fees: if fee.move: if fee.move or fee.paid: raise AccessError( gettext('association.msg_fee_delete', fee=fee.rec_name)) super(Fee, cls).delete(fees) Loading Loading @@ -281,13 +300,19 @@ class Fee(ModelSQL, ModelView): return max(r.date for r in reconciliations) def is_paid(self, name): return self.reconciled is not None return (self.reconciled is not None) or ( self.move is None and self.period.amount == Decimal('0.0')) def get_move_line(self, amount, amount_second_currency): 'Return counterpart Move Line for the amount' pool = Pool() MoveLine = pool.get('account.move.line') if not self.member.party.account_receivable: raise PartyAccountPayableRequiredError( gettext('association.msg_party_receivable_missing', party=self.member.party.rec_name)) account = None if amount > 0: account = self.member.party.account_receivable Loading Loading @@ -334,12 +359,13 @@ class Fee(ModelSQL, ModelView): pool = Pool() Date = pool.get('ir.date') Move = pool.get("account.move") to_post = [] for fee in fees: if not fee.move: if not fee.move and fee.period.amount > Decimal('0.0'): fee.move = fee.create_move(Date.today()) fee.save() to_post.append(fee.move) to_post = [x.move for x in fees] Move.post(to_post) @classmethod Loading message.xml +6 −0 Original line number Diff line number Diff line Loading @@ -15,5 +15,11 @@ this repository contains the full copyright notices and license terms. --> <record model="ir.message" id="msg_unpaid_fee"> <field name="text">You cannot expel the member "%(member)s" because there are still some unpaid fees</field> </record> <record model="ir.message" id="msg_period_fee_invalid"> <field name="text">The period "%(period)s" fee amount should have a value >= zero</field> </record> <record model="ir.message" id="msg_party_receivable_missing"> <field name="text">The party "%(party)s" doesn't have an account receivable defined</field> </record> </data> </tryton> No newline at end of file tests/scenario_honorary_membership.rst 0 → 100644 +111 −0 Original line number Diff line number Diff line =============== Member Scenario =============== Imports:: >>> import datetime >>> from dateutil.relativedelta import relativedelta >>> from decimal import Decimal >>> from operator import attrgetter >>> from proteus import Model, Wizard >>> from trytond.tests.tools import activate_modules >>> from trytond.modules.company.tests.tools import create_company, \ ... get_company >>> from trytond.modules.account.tests.tools import create_fiscalyear, \ ... create_chart, get_accounts, create_tax, create_tax_code >>> from trytond.modules.association.tests.tools import create_period >>> from decimal import * >>> today = datetime.date.today() Install association:: >>> config = activate_modules('association') Create company:: >>> _ = create_company() >>> company = get_company() Create fiscal year:: >>> fiscalyear = create_fiscalyear(company) >>> fiscalyear.click('create_period') >>> period = fiscalyear.periods[0] Create chart of accounts:: >>> _ = create_chart(company) >>> accounts = get_accounts(company) >>> receivable = accounts['receivable'] >>> payable = accounts['payable'] >>> revenue = accounts['revenue'] >>> expense = accounts['expense'] >>> account_tax = accounts['tax'] >>> account_cash = accounts['cash'] Create party:: >>> Party = Model.get('party.party') >>> party = Party(name='Party') >>> party.account_payable = payable >>> party.account_receivable = receivable >>> party.save() Create member:: >>> Member = Model.get('association.member') >>> member = Member() >>> member.party = party >>> member.save() Get a revenue journal:: >>> Journal = Model.get('account.journal') >>> journal_revenue, = Journal.find([ ... ('code', '=', 'REV'), ... ]) Create honorary membership:: >>> Membership = Model.get('association.membership') >>> Period = Model.get('association.membership.period') >>> honorary = Membership() >>> honorary.party = party >>> honorary.name = "testo" >>> honorary.account_revenue = revenue >>> honorary.journal = journal_revenue >>> start_date = datetime.date(2018,1,1) >>> timedelta = datetime.timedelta(weeks=4) >>> fiscalMonth = 13 >>> datedeltas = [(start_date + timedelta*(n-1) + datetime.timedelta(days=1),start_date + timedelta*n,f"period {n}") for n in range(1,fiscalMonth+1)] >>> _ = [ honorary.periods.new(start_date=period[0],end_date=period[1], name=period[2], amount=Decimal('0.0')) for period in datedeltas ] >>> honorary.save() Add membership to member:: >>> _ = member.memberships.new(membership=honorary) >>> member.save() Enroll member:: >>> member.join_date = start_date >>> member.click("run") >>> member.save() Create fee lines:: >>> create_fee = Wizard('association.membership.fee_create') >>> create_fee.form.date = start_date + datetime.timedelta(weeks=40) >>> create_fee.execute('create_') Check fees:: >>> Fee = Model.get('association.membership.fee') >>> honorary_fees = Fee.find([('period.amount','=', Decimal('0.0'))]) >>> all([x.paid for x in honorary_fees]) and len(honorary_fees) > 0 True tests/scenario_membership.rst +9 −4 Original line number Diff line number Diff line Loading @@ -79,12 +79,17 @@ Create membership:: >>> datedeltas = [(start_date + timedelta*(n-1) + datetime.timedelta(days=1),start_date + timedelta*n,f"period {n}") for n in range(1,fiscalMonth+1)] >>> periods = [ membership.periods.new(start_date=period[0],end_date=period[1], name=period[2], amount=Decimal(42)) for period in datedeltas ] >>> membership.save() >>> overlapping =membership.periods.new(start_date=start_date,end_date=start_date+timedelta,name="overlapping",amount=Decimal(42)).save() >>> overlapping =membership.periods.new(start_date=start_date,end_date=start_date+timedelta,name="overlapping",amount=Decimal(42)).save() #doctest: +ELLIPSIS Traceback (most recent call last): ... trytond.modules.association.exceptions.PeriodDateOverlapError: ('UserError', ('The period "overlapping" overlaps with the period "period 1"', '')) trytond.modules.association.exceptions.PeriodDateOverlapError: ('UserError', ...) >>> new_date = datetime.date(2020,1,1) >>> inverted = membership.periods.new(start_date=new_date + timedelta,end_date=start_date,name="inverted",amount=Decimal(42)).save() >>> inverted = membership.periods.new(start_date=new_date + timedelta,end_date=start_date,name="inverted",amount=Decimal(42)).save() #doctest: +ELLIPSIS Traceback (most recent call last): ... trytond.model.modelstorage.DomainValidationError: ('UserError', ('The value for field "Ending Date" in "Membership period" is not valid according to its domain.', '')) trytond.model.modelstorage.DomainValidationError: ('UserError', ...) >>> negative = membership.periods.new(start_date=new_date,end_date=new_date + timedelta,name="negative 1",amount=Decimal(-10)).save() #doctest: +ELLIPSIS Traceback (most recent call last): ... trytond.modules.association.exceptions.AmountLessThanZeroError: ('UserError', ...) Loading
exceptions.py +8 −0 Original line number Diff line number Diff line Loading @@ -22,3 +22,11 @@ class SequenceMissingError(ValidationError): class MemberUnpaidFeeError(ValidationError): pass class AmountLessThanZeroError(ValidationError): pass class PartyAccountPayableRequiredError(ValidationError): pass
membership.py +35 −9 Original line number Diff line number Diff line # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. from decimal import Decimal from trytond.config import config from trytond.i18n import gettext Loading @@ -11,7 +10,8 @@ from trytond.model.exceptions import AccessError from trytond.transaction import Transaction from trytond.wizard import StateTransition, Wizard, StateView, Button from .exceptions import PeriodDateOverlapError from .exceptions import (PeriodDateOverlapError, AmountLessThanZeroError, PartyAccountPayableRequiredError) __all__ = [ 'Membership', 'Period', 'Fee', 'Line', 'PostFee', 'GenerateFee', Loading @@ -25,7 +25,10 @@ _STATES = {'readonly': Eval('state') != 'open'} _LINE_STATES = {'readonly': Eval('member_state', '') != 'draft'} _FEE_STATES = {'readonly': Bool(Eval('move'))} _FEE_STATES = { 'readonly': Bool(Eval('move')) and Eval('fee', Decimal('0.0')) == Decimal('0.0') } _MEMBER_STATES = [ ('draft', 'Draft'), Loading Loading @@ -167,6 +170,13 @@ class Period(ModelSQL, ModelView): super(Period, cls).validate(periods) for period in periods: period.check_dates() period.check_fee() def check_fee(self): if self.amount < Decimal('0.0'): raise AmountLessThanZeroError( gettext('association.msg_period_fee_invalid', period=self.rec_name)) def check_dates(self): transaction = Transaction() Loading Loading @@ -221,6 +231,13 @@ class Fee(ModelSQL, ModelView): "Move State"), 'on_change_with_move_state') paid = fields.Function(fields.Boolean('paid'), 'is_paid') reconciled = fields.Function(fields.Date('Reconciled'), 'get_reconciled') amount = fields.Function(fields.Numeric("Amount", fee_digit), 'on_change_with_amount') @fields.depends('period', '_parent_period.amount') def on_change_with_amount(self, name=None): if self.period: return self.period.amount @classmethod def __setup__(cls): Loading @@ -234,8 +251,10 @@ class Fee(ModelSQL, ModelView): cls._buttons.update({ 'post_move': { 'invisible': Eval('move_state') == 'posted', 'depends': ['move_state'], 'invisible': (Eval('move_state') == 'posted') | (Eval('amount', Decimal('0.0')) == Decimal('0.0')), 'depends': ['move_state', 'amount'], }, }) Loading @@ -247,7 +266,7 @@ class Fee(ModelSQL, ModelView): @classmethod def delete(cls, fees): for fee in fees: if fee.move: if fee.move or fee.paid: raise AccessError( gettext('association.msg_fee_delete', fee=fee.rec_name)) super(Fee, cls).delete(fees) Loading Loading @@ -281,13 +300,19 @@ class Fee(ModelSQL, ModelView): return max(r.date for r in reconciliations) def is_paid(self, name): return self.reconciled is not None return (self.reconciled is not None) or ( self.move is None and self.period.amount == Decimal('0.0')) def get_move_line(self, amount, amount_second_currency): 'Return counterpart Move Line for the amount' pool = Pool() MoveLine = pool.get('account.move.line') if not self.member.party.account_receivable: raise PartyAccountPayableRequiredError( gettext('association.msg_party_receivable_missing', party=self.member.party.rec_name)) account = None if amount > 0: account = self.member.party.account_receivable Loading Loading @@ -334,12 +359,13 @@ class Fee(ModelSQL, ModelView): pool = Pool() Date = pool.get('ir.date') Move = pool.get("account.move") to_post = [] for fee in fees: if not fee.move: if not fee.move and fee.period.amount > Decimal('0.0'): fee.move = fee.create_move(Date.today()) fee.save() to_post.append(fee.move) to_post = [x.move for x in fees] Move.post(to_post) @classmethod Loading
message.xml +6 −0 Original line number Diff line number Diff line Loading @@ -15,5 +15,11 @@ this repository contains the full copyright notices and license terms. --> <record model="ir.message" id="msg_unpaid_fee"> <field name="text">You cannot expel the member "%(member)s" because there are still some unpaid fees</field> </record> <record model="ir.message" id="msg_period_fee_invalid"> <field name="text">The period "%(period)s" fee amount should have a value >= zero</field> </record> <record model="ir.message" id="msg_party_receivable_missing"> <field name="text">The party "%(party)s" doesn't have an account receivable defined</field> </record> </data> </tryton> No newline at end of file
tests/scenario_honorary_membership.rst 0 → 100644 +111 −0 Original line number Diff line number Diff line =============== Member Scenario =============== Imports:: >>> import datetime >>> from dateutil.relativedelta import relativedelta >>> from decimal import Decimal >>> from operator import attrgetter >>> from proteus import Model, Wizard >>> from trytond.tests.tools import activate_modules >>> from trytond.modules.company.tests.tools import create_company, \ ... get_company >>> from trytond.modules.account.tests.tools import create_fiscalyear, \ ... create_chart, get_accounts, create_tax, create_tax_code >>> from trytond.modules.association.tests.tools import create_period >>> from decimal import * >>> today = datetime.date.today() Install association:: >>> config = activate_modules('association') Create company:: >>> _ = create_company() >>> company = get_company() Create fiscal year:: >>> fiscalyear = create_fiscalyear(company) >>> fiscalyear.click('create_period') >>> period = fiscalyear.periods[0] Create chart of accounts:: >>> _ = create_chart(company) >>> accounts = get_accounts(company) >>> receivable = accounts['receivable'] >>> payable = accounts['payable'] >>> revenue = accounts['revenue'] >>> expense = accounts['expense'] >>> account_tax = accounts['tax'] >>> account_cash = accounts['cash'] Create party:: >>> Party = Model.get('party.party') >>> party = Party(name='Party') >>> party.account_payable = payable >>> party.account_receivable = receivable >>> party.save() Create member:: >>> Member = Model.get('association.member') >>> member = Member() >>> member.party = party >>> member.save() Get a revenue journal:: >>> Journal = Model.get('account.journal') >>> journal_revenue, = Journal.find([ ... ('code', '=', 'REV'), ... ]) Create honorary membership:: >>> Membership = Model.get('association.membership') >>> Period = Model.get('association.membership.period') >>> honorary = Membership() >>> honorary.party = party >>> honorary.name = "testo" >>> honorary.account_revenue = revenue >>> honorary.journal = journal_revenue >>> start_date = datetime.date(2018,1,1) >>> timedelta = datetime.timedelta(weeks=4) >>> fiscalMonth = 13 >>> datedeltas = [(start_date + timedelta*(n-1) + datetime.timedelta(days=1),start_date + timedelta*n,f"period {n}") for n in range(1,fiscalMonth+1)] >>> _ = [ honorary.periods.new(start_date=period[0],end_date=period[1], name=period[2], amount=Decimal('0.0')) for period in datedeltas ] >>> honorary.save() Add membership to member:: >>> _ = member.memberships.new(membership=honorary) >>> member.save() Enroll member:: >>> member.join_date = start_date >>> member.click("run") >>> member.save() Create fee lines:: >>> create_fee = Wizard('association.membership.fee_create') >>> create_fee.form.date = start_date + datetime.timedelta(weeks=40) >>> create_fee.execute('create_') Check fees:: >>> Fee = Model.get('association.membership.fee') >>> honorary_fees = Fee.find([('period.amount','=', Decimal('0.0'))]) >>> all([x.paid for x in honorary_fees]) and len(honorary_fees) > 0 True
tests/scenario_membership.rst +9 −4 Original line number Diff line number Diff line Loading @@ -79,12 +79,17 @@ Create membership:: >>> datedeltas = [(start_date + timedelta*(n-1) + datetime.timedelta(days=1),start_date + timedelta*n,f"period {n}") for n in range(1,fiscalMonth+1)] >>> periods = [ membership.periods.new(start_date=period[0],end_date=period[1], name=period[2], amount=Decimal(42)) for period in datedeltas ] >>> membership.save() >>> overlapping =membership.periods.new(start_date=start_date,end_date=start_date+timedelta,name="overlapping",amount=Decimal(42)).save() >>> overlapping =membership.periods.new(start_date=start_date,end_date=start_date+timedelta,name="overlapping",amount=Decimal(42)).save() #doctest: +ELLIPSIS Traceback (most recent call last): ... trytond.modules.association.exceptions.PeriodDateOverlapError: ('UserError', ('The period "overlapping" overlaps with the period "period 1"', '')) trytond.modules.association.exceptions.PeriodDateOverlapError: ('UserError', ...) >>> new_date = datetime.date(2020,1,1) >>> inverted = membership.periods.new(start_date=new_date + timedelta,end_date=start_date,name="inverted",amount=Decimal(42)).save() >>> inverted = membership.periods.new(start_date=new_date + timedelta,end_date=start_date,name="inverted",amount=Decimal(42)).save() #doctest: +ELLIPSIS Traceback (most recent call last): ... trytond.model.modelstorage.DomainValidationError: ('UserError', ('The value for field "Ending Date" in "Membership period" is not valid according to its domain.', '')) trytond.model.modelstorage.DomainValidationError: ('UserError', ...) >>> negative = membership.periods.new(start_date=new_date,end_date=new_date + timedelta,name="negative 1",amount=Decimal(-10)).save() #doctest: +ELLIPSIS Traceback (most recent call last): ... trytond.modules.association.exceptions.AmountLessThanZeroError: ('UserError', ...)