Commit d89e3c4c authored by Luca Cristaldi's avatar Luca Cristaldi

Merge branch 'membership-fee' into 'master'

Membership fee

See merge request !1
parents 5e486fc2 83d9a785
......@@ -4,7 +4,13 @@
#dev files
*.sqlite
*.conf
.eggs
__pycache__
*.egg-info
# pipenv
Pipfile
Pipfile.lock
#Stylesheets
stylesheets
from trytond.pool import Pool
from .association import *
from .account import *
from .member import *
from .membership import *
from .configuration import *
from .ir import *
__all__ = ['register']
def register():
Pool.register(
Member, Configuration, MemberSequence,
Member,
Configuration,
MemberSequence,
Membership,
Period,
Fee,
Line,
Move,
GenerateFeeStart,
Cron,
PrintMembersBookStart,
module='association', type_='model')
Pool.register(
PostFee,
GenerateFee,
MembersBookWizard,
module='association', type_='wizard')
Pool.register(
module='association', type_='report')
MembersBookReport,
module='association', type_='report')
from trytond.pool import PoolMeta, Pool
__all__ = ['Move']
class Move(metaclass=PoolMeta):
__name__ = 'account.move'
@classmethod
def _get_origin(cls):
return super(Move, cls)._get_origin() + ['association.membership.fee']
\ No newline at end of file
Association Module
##################
===========
Association
===========
------
Member
------
A member is defined by:
* code: a sequential code, you can specify the format in the Configuration tab
* Party: The party associated with this member
* join_date: The date were the member joins the association
* leave_date: The date were the member leaves the association
* memberships: A list of Mebership Line
If you want easilly check if the member paied all his fees, you can use the "Fees" relate utility.
---------------
Membership Line
---------------
The membership line is used to specify the starting and ending date of the membership.
This so you can selecet a starting/ending date different from the member one.
If you leave the fields empty, it will use the join/leave date from the member.
A membership line is defined by:
* membership: the membership the member enrolled
* start_date: the strating date, if not specified use the join date from the member will be used
* end_date: the ending date, if not specified the leave date from the member will be used
This is used by the "Create Fees" Wizard to filter wich period the user should be billed
----------
Membership
----------
A membership is a collection of Periods.
The list must contain Periods with no overlapping date.
A Membership is defined by:
* name: The name of the Membership
* journal: The Journal that will be used for posting accounting move
* account_revenue: The account revenue that will be used for posting accounting move
* periods: A list of Periods
*******************
Print Member's Book
*******************
This wizard will print a Report of all the member that are not in the draft state.
------
Period
------
A periods define a date range and what the member should pay for the period
A period is defined by:
* start_date: the starting date of the period
* end_date: the ending date of the period
* fee: the amount that the member should pay for this period
---
Fee
---
A fee is associated to a Member and a Period.
Tryton will check if a Member have Fees with the same period,and will throw an error if you try to do so.
A feee is defined as:
* member: The Member associated with this fee
* period: The Period associated with this fee
* move: The move associated with this fee
The module will check if the move is reconciled and posted, if so the paid field will be checked.
You can post the move via the Post button/action, or manually.
******************
Create Fees Wizard
******************
The wizard will generate fee record based on the date.
This wizard is used by the cronjob that will trigger (by default) once every day.
from trytond.model.exceptions import ValidationError
class PeriodValidationError(ValidationError):
pass
class PeriodDateOverlapError(PeriodValidationError):
pass
class DateNotInPeriodError(PeriodValidationError):
pass
class SequenceMissingError(ValidationError):
pass
class MemberUnpaiedFeeError(ValidationError):
pass
from trytond.pool import PoolMeta
class Cron(metaclass=PoolMeta):
__name__ = 'ir.cron'
@classmethod
def __setup__(cls):
super().__setup__()
cls.method.selection.extend([
('association.membership.fee|generate_fees',
"Generate Fee Lines"),
])
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<?xml version="1.0" ?>
<tryton>
<data depends="">
<!-- Membership -->
<record model="ir.ui.view" id="view_membership_tree">
<field name="model">association.membership</field>
<field name="type">tree</field>
<field name="name">membership_tree</field>
</record>
<record model="ir.ui.view" id="view_membership_form">
<field name="model">association.membership</field>
<field name="type">form</field>
<field name="name">membership_form</field>
</record>
<record model="ir.action.act_window" id="act_membership_form">
<field name="name">Memberships</field>
<field name="res_model">association.membership</field>
</record>
<record model="ir.action.act_window.view" id="act_membership_form1">
<field name="sequence">10</field>
<field name="view" ref="view_membership_tree" />
<field name="act_window" ref="view_membership_form" />
</record>
<record model="ir.action.act_window.view" id="act_membership_form2">
<field name="sequence">20</field>
<field name="view" ref="view_membership_form" />
<field name="act_window" ref="view_membership_tree" />
</record>
<record model="ir.ui.view" id="membership_period_view_tree_editable">
<field name="model">association.membership</field>
<field name="type">tree</field>
<field name="priority" eval="20" />
<field name="name">membership_period_tree_editable</field>
</record>
<menuitem name="Memberships" sequence="20" parent="menu_configuration" id="menu_membership" action="act_membership_form" icon="tryton-list" />
<!-- membership fee -->
<record model="ir.ui.view" id="membership_fee_view_tree">
<field name="model">association.membership.fee</field>
<field name="type">tree</field>
<field name="priority" eval="20" />
<field name="name">membership_fee_tree</field>
</record>
<record model="ir.ui.view" id="membership_fee_view_form">
<field name="model">association.membership.fee</field>
<field name="type">form</field>
<field name="priority" eval="10" />
<field name="name">membership_fee_form</field>
</record>
<record model="ir.action.act_window" id="act_fee_form">
<field name="name">Fees</field>
<field name="res_model">association.membership.fee</field>
</record>
<record model="ir.action.act_window.view" id="act_fee_form1">
<field name="sequence">10</field>
<field name="view" ref="membership_fee_view_tree" />
<field name="act_window" ref="membership_fee_view_form" />
</record>
<record model="ir.action.act_window.view" id="act_fee_form2">
<field name="sequence">10</field>
<field name="view" ref="membership_fee_view_form" />
<field name="act_window" ref="membership_fee_view_tree" />
</record>
<menuitem name="Fees" sequence="30" parent="menu_association" id="menu_fee" action="act_fee_form" icon="tryton-list" />
<!-- Fee action generate/post move -->
<record model="ir.model.button" id="membership_fee_button">
<field name="name">post_move</field>
<field name="string">Post</field>
<field name="model" search="[('model', '=', 'association.membership.fee')]" />
</record>
<record model="ir.action.wizard" id="act_generate_move">
<field name="name">Generate Fee move</field>
<field name="wiz_name">association.membership.fee_move</field>
<field name="model">association.membership.fee</field>
</record>
<record model="ir.action.keyword" id="membership_fee_action_keyword">
<field name="keyword">form_action</field>
<field name="model">association.membership.fee,-1</field>
<field name="action" ref="act_generate_move" />
</record>
<!-- Fee wizard -->
<record model="ir.action.wizard" id="act_membership_fee_create">
<field name="name">Create Fees</field>
<field name="wiz_name">association.membership.fee_create</field>
</record>
<record model="ir.action.keyword" id="act_membership_fee_create_keyword">
<field name="keyword">form_action</field>
<field name="model">association.member,-1</field>
<field name="action" ref="act_membership_fee_create" />
</record>
<record model="ir.ui.view" id="create_membership_fee_view_form">
<field name="model">association.membership.fee_create.start</field>
<field name="type">form</field>
<field name="name">membership_fee_wizard_form</field>
</record>
<menuitem parent="menu_association" sequence="90" action="act_membership_fee_create" id="menu_act_membership_fee_create" />
<!-- membership line -->
<record model="ir.ui.view" id="membership_line_view_tree_editable">
<field name="model">association.membership.line</field>
<field name="type">tree</field>
<field name="priority" eval="30" />
<field name="name">membership_line_tree_editable</field>
</record>
<!-- membership period -->
<record model="ir.ui.view" id="view_membership_period_tree">
<field name="model">association.membership.period</field>
<field name="type">tree</field>
<field name="name">membership_period_tree</field>
</record>
<record model="ir.ui.view" id="view_membership_period_form">
<field name="model">association.membership.period</field>
<field name="type">form</field>
<field name="name">membership_period_form</field>
</record>
<record model="ir.action.act_window" id="act_membership_period_form">
<field name="name">Membership Period</field>
<field name="res_model">association.membership.period</field>
</record>
<record model="ir.action.act_window.view" id="act_membership_period_form1">
<field name="sequence">10</field>
<field name="view" ref="view_membership_period_tree" />
<field name="act_window" ref="view_membership_period_form" />
</record>
<record model="ir.action.act_window.view" id="act_membership_period_form2">
<field name="sequence">20</field>
<field name="view" ref="view_membership_period_form" />
<field name="act_window" ref="view_membership_period_tree" />
</record>
<!-- membership fee relate -->
<record model="ir.action.act_window" id="act_fees_form_relate">
<field name="name">Fees</field>
<field name="res_model">association.membership.fee</field>
<field name="domain" eval="[('member.id', 'in', Eval('active_ids'))]" pyson="1" />
</record>
<record model="ir.action.keyword" id="act_open_fees_form_relate">
<field name="keyword">form_relate</field>
<field name="model">association.member,-1</field>
<field name="action" ref="act_fees_form_relate" />
</record>
<!-- Create Fees Cronjob -->
<record model="ir.cron" id="cron_generate_invoice">
<field name="method">association.membership.fee|generate_fees</field>
<field name="interval_number" eval="1" />
<field name="interval_type">days</field>
</record>
</data>
</tryton>
\ No newline at end of file
<tryton>
<data group="1">
<record model="ir.message" id="msg_date_non_in_period">
<field name="text">The date is outside the "%(period)s"</field>
</record>
<record model="ir.message" id="msg_overlapping_period">
<field name="text">The period "%(first)s" overlaps with the period "%(second)s"</field>
</record>
<record model="ir.message" id="msg_fee_delete">
<field name="text">You cannot delete the fee "%(fee)s"</field>
</record>
<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>
</data>
</tryton>
\ No newline at end of file
===============
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 memberships::
>>> Journal = Model.get('account.journal')
>>> Move = Model.get('account.move')
>>> journal_revenue, = Journal.find([
... ('code', '=', 'REV'),
... ])
>>> Membership = Model.get('association.membership')
>>> membership = Membership()
>>> membership.party = party
>>> membership.name = "testo"
>>> membership.journal = journal_revenue
>>> membership.account_revenue = revenue
>>> start_date = datetime.date.today()
>>> 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)]
>>> periods = [ membership.periods.new(start_date=period[0],end_date=period[1], name=period[2], amount=Decimal(42)) for period in datedeltas ]
>>> membership.save()
Create member::
>>> Member = Model.get('association.member')
>>> member = Member()
>>> member.party = party
>>> member.company = company
>>> membership_line = member.memberships.new(membership=membership)
>>> member.save()
>>> membership_line.start_date = start_date
>>> membership_line.end_date = start_date + timedelta
Test membership inverse date::
>>> membership_line = member.memberships.new(
... membership=membership,
... start_date=start_date +timedelta,
... end_date=start_date)
>>> member.save() #doctest: +ELLIPSIS
Traceback (most recent call last):
...
trytond.model.modelstorage.DomainValidationError: ('UserError', (...))
>>> member.memberships.remove(membership_line)
>>> member.save()
Test membership unique::
>>> membership_line = member.memberships.new(membership=membership)
>>> member.save()
Test memebrship fee::
>>> Period = Model.get('association.membership.period')
>>> Fee = Model.get('association.membership.fee')
>>> period, = Period.find(["name","=","period 1"])
>>> fee = Fee(member=member,period=period)
>>> fee.save() #doctest: +ELLIPSIS
Traceback (most recent call last):
...
trytond.model.modelstorage.DomainValidationError: ('UserError', (...))
Make member active::
>>> member.click('run')
>>> member.state
'running'
Test memebrship fee move::
>>> Period = Model.get('association.membership.period')
>>> Fee = Model.get('association.membership.fee')
>>> period, = Period.find(["name","=","period 1"])
>>> fee = Fee(member=member,period=period)
>>> fee.save()
>>> fee.click('post_move')
>>> fee.move #doctest: +ELLIPSIS
proteus.Model.get('account.move')(...)
Test member expulsion::
>>> member.click('stop') #doctest: +ELLIPSIS
Traceback (most recent call last):
...
trytond.modules.association.exceptions.MemberUnpaiedFeeError: (...)
>>> move_wiz = Wizard('account.move.cancel',[fee.move])
>>> move_wiz.form.description = 'Cancel'
>>> move_wiz.execute('cancel')
>>> fee.move.reload()
>>> fee.paid
True
>>> member.click('stop')
>>> member.state
'stopped'
===============
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']
>>> 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.save()
Create member::
>>> Member = Model.get('association.member')
>>> member = Member()
>>> member.party = party
>>> member.save()
Create membership::
>>> Membership = Model.get('association.membership')
>>> Period = Model.get('association.membership.period')
>>> membership = Membership()
>>> membership.party = party
>>> membership.name = "testo"
>>> membership.account_revenue = 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)]
>>> 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()
Traceback (most recent call last):
...
trytond.modules.association.exceptions.PeriodDateOverlapError: ('UserError', ('The period "overlapping" overlaps with the period "period 1"', ''))
>>> 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()
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.', ''))
===============
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()
Create membership::
>>> start_date = datetime.date(2018,1,1)
>>> fiscalMonth = 13
>>> timedelta = datetime.timedelta(weeks=4)
>>> Membership = Model.get('association.membership')
>>> Period = Model.get('association.membership.period')
>>> membership1 = Membership()
>>> membership1.party = party
>>> membership1.name = "membership1"
>>> membership1.account_revenue = revenue
>>> datedeltas = [(start_date + timedelta*(n-1) + datetime.timedelta(days=1),start_date + timedelta*n,f"{membership1.name} - {n}") for n in range(1,fiscalMonth+1)]
>>> periods1 = [ membership1.periods.new(start_date=period[0],end_date=period[1], name=period[2], amount=Decimal(42)) for period in datedeltas ]
>>> membership1.save()
>>> start_date = datetime.date(2018,1,1)
>>> membership2 = Membership()
>>> membership2.party = party
>>> membership2.name = "membership2:Electric boogaloo"
>>> membership2.account_revenue = revenue
>>> datedeltas = [(start_date + timedelta*(n-1) + datetime.timedelta(days=1),start_date + timedelta*n,f"{membership2.name} - {n}") for n in range(1,fiscalMonth+1)]
>>> periods2 = [ membership2.periods.new(start_date=period[0],end_date=period[1], name=period[2], amount=Decimal(42)) for period in datedeltas ]
>>> membership2.save()
Add membership to member::
>>> memperships = [ membership1, membership2 ]
>>> _ = [ member.memberships.new(membership=m,start_date = start_date + datetime.timedelta(weeks=4),end_date = start_date + datetime.timedelta(weeks=50)) for m in memperships]
>>> member.join_date = start_date + datetime.timedelta(weeks=6)
>>> member.leave_date = start_date + datetime.timedelta(weeks=35)
>>> 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_')
import unittest
import doctest
from trytond.tests.test_tryton import ModuleTestCase
from trytond.tests.test_tryton import suite as test_suite
from trytond.tests.test_tryton import doctest_teardown, doctest_checker
class AssociationTestCase(ModuleTestCase):
......@@ -12,6 +13,24 @@ class AssociationTestCase(ModuleTestCase):
def suite():
suite = test_suite()
suite.addTests(unittest.TestLoader().loadTestsFromTestCase(
AssociationTestCase))
suite.addTests(
unittest.TestLoader().loadTestsFromTestCase(AssociationTestCase))
suite.addTests(
doctest.DocFileSuite('scenario_membership.rst',
tearDown=doctest_teardown,
encoding='utf-8',
optionflags=doctest.REPORT_ONLY_FIRST_FAILURE,
checker=doctest_checker))
suite.addTests(
doctest.DocFileSuite('scenario_member.rst',
tearDown=doctest_teardown,
encoding='utf-8',
optionflags=doctest.REPORT_ONLY_FIRST_FAILURE,
checker=doctest_checker))
suite.addTests(
doctest.DocFileSuite('scenario_membership_fee.rst',
tearDown=doctest_teardown,
encoding='utf-8',
optionflags=doctest.REPORT_ONLY_FIRST_FAILURE,
checker=doctest_checker))
return suite
import datetime
from dateutil.relativedelta import relativedelta
from proteus import Model, Wizard
from decimal import *
__all__ = ['create_period']
def create_period(start, end, membership, amount):
Period = Model.get('association.membership.period')
period = Period()
# period.membership = membership
period.start_date = start
period.end_date = end
period.amount = Decimal(amount)
period.membership = membership
period.name = "test"
period.save()
return period
\ No newline at end of file
[tryton]
version=5.0.0
version=5.2.0
depends:
ir
company
party
account
xml:
association.xml
configuration.xml
\ No newline at end of file
member.xml
configuration.xml