Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Seasonal income classes #198

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 72 additions & 22 deletions docassemble/ALToolbox/al_income.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@
"ALVehicleList",
"ALSimpleValue",
"ALSimpleValueList",
"ALItemizedValue",
"ALItemizedValueDict",
"ALItemizedValue", # Rationale for exporting this?
"ALItemizedValueDict", # Rationale for exporting this?
"ALItemizedJob",
"ALItemizedJobList",
]
Expand Down Expand Up @@ -926,7 +926,7 @@ def __str__(self) -> str:
to_stringify.append((key, "{:.2f}".format(self[key].value)))
pretty = json.dumps(to_stringify, indent=2)
return pretty


class ALItemizedJob(DAObject):
"""
Expand All @@ -941,6 +941,7 @@ class ALItemizedJob(DAObject):
- Overtime at a second hourly rate
- Tips earned during that time period
- A fixed salary earned for that pay period
- Income and deductions from a seasonal job
- Union Dues
- Insurance
- Taxes
Expand All @@ -958,12 +959,18 @@ class ALItemizedJob(DAObject):
represents how frequently the income is earned
.is_hourly {bool} (Optional) Whether the value represents a figure that the
user earns on an hourly basis, rather than for the full time period
.hours_per_period {int} (Optional) If the job is hourly, how many hours the
user works per period.
.hours_per_period {float | Decimal} (Optional) If the job is hourly, how
many hours the user works per period.
.is_seasonal {bool} (Optional) Whether the job's income changes drastically
during different times of year.
.employer {Individual} (Optional) Individual assumed to have a name and,
optionally, an address and phone.
.source {str} (Optional) The category of this item, like "public service".
Defaults to "job".
.months {ALItemizedJobMonthList} Automatically exist, but they won't be used
plocket marked this conversation as resolved.
Show resolved Hide resolved
unless the `is_seasonal` property is set to True. Then the give monthly
values will be added into the total of the job. You can still use the job
as a regular job so that a job can be seasonal, but still accept a single
value for the whole year.

WARNING: Individual items in `.to_add` and `.to_subtract` should not be used
directly. They should only be accessed through the filtering methods of
Expand All @@ -985,8 +992,6 @@ class ALItemizedJob(DAObject):

def init(self, *pargs, **kwargs):
super().init(*pargs, **kwargs)
# if not hasattr(self, "source") or self.source is None:
# self.source = "job"
if not hasattr(self, "employer"):
if hasattr(self, "employer_type"):
self.initializeAttribute("employer", self.employer_type)
Expand All @@ -998,6 +1003,11 @@ def init(self, *pargs, **kwargs):
# Money being taken out
if not hasattr(self, "to_subtract"):
self.initializeAttribute("to_subtract", ALItemizedValueDict)

# Every non-month job will have .months, though not all jobs will use them
add_months = kwargs.get('add_months', True)
if add_months:
self.initializeAttribute("months", ALItemizedJobMonthList)

def _item_value_per_times_per_year(
self, item: ALItemizedValue, times_per_year: float = 1
Expand Down Expand Up @@ -1028,26 +1038,28 @@ def _item_value_per_times_per_year(
else:
frequency_to_use = self.times_per_year

# NOTE: fixes a bug that was present < 0.8.2
try:
hours_per_period = Decimal(self.hours_per_period)
except:
log(
word(
"Your hours per period need to be just a single number, without words"
),
"danger",
)
delattr(self, "hours_per_period")
self.hours_per_period # Will cause another exception

nonprofittechy marked this conversation as resolved.
Show resolved Hide resolved
# Both the job and the item itself need to be hourly to be
# calculated as hourly
is_hourly = self.is_hourly and hasattr(item, "is_hourly") and item.is_hourly
is_hourly = hasattr(self, "is_hourly") and self.is_hourly and hasattr(item, "is_hourly") and item.is_hourly
value = item.total()

# Use the appropriate calculation
if is_hourly:
# NOTE: fixes a bug that was present < 0.8.2
# What's the bug? What's the issue #? How to test for it?
try:
hours_per_period = Decimal(self.hours_per_period)
except:
if not self.hours_per_period.isdigit():
# Shouldn't this input just be a datatype number to make sure?
log(word(
"Your hours per period need to be just a single number, without words"
), "danger",)
else:
log(word("Your hours per period may be wrong"), "danger",)
delattr(self, "hours_per_period")
self.hours_per_period # Will cause another exception

return (
value * Decimal(hours_per_period) * Decimal(frequency_to_use)
) / Decimal(times_per_year)
Expand Down Expand Up @@ -1095,6 +1107,10 @@ def gross_total(
total += self._item_value_per_times_per_year(
value, times_per_year=times_per_year
)
if hasattr(self, 'is_seasonal') and self.is_seasonal:
total += self.months.gross_total(
times_per_year=times_per_year, source=source, exclude_source=exclude_source
)
return total

def deduction_total(
Expand Down Expand Up @@ -1317,3 +1333,37 @@ def net_total(
) - self.deduction_total(
times_per_year=times_per_year, source=source, exclude_source=exclude_source
)


class ALItemizedJobMonth(ALItemizedJob):
"""
"""
def init(self, *pargs, **kwargs):
kwargs['add_months'] = kwargs.get('add_months', False)
kwargs['is_hourly'] = kwargs.get('is_hourly', False)
kwargs['times_per_year'] = kwargs.get('times_per_year', 1)
super().init(*pargs, **kwargs)

self.to_add.there_are_any = True
self.to_subtract.there_are_any = True


class ALItemizedJobMonthList(ALItemizedJobList):
"""
"""
def init(self, *pargs, **kwargs):
kwargs['source'] = kwargs.get('source', "months")
kwargs['object_type'] = kwargs.get('object_type', ALItemizedJobMonth)
kwargs['ask_number'] = kwargs.get('ask_number', True)
kwargs['target_number'] = kwargs.get('target_number', 12)

kwargs['add_months'] = kwargs.get('add_months', False)
super().init(*pargs, **kwargs)

month_names = [
"january", "february", "march", "april", "may", "june",
plocket marked this conversation as resolved.
Show resolved Hide resolved
"july", "august", "september", "october", "november"
]
for month_name in month_names:
month = self.appendObject(source=month_name)

60 changes: 60 additions & 0 deletions docassemble/ALToolbox/data/questions/al_income_demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,66 @@ metadata:
include:
- al_income.yml
---
mandatory: True
code: |
class ALSeasonalItemizedJob(ALItemizedJobList):
"""
Attributes:
.to_add {ALItemizedValueDict} Dict of ALItemizedValues that would be added
to a job's net total, like wages and tips.
.to_subtract {ALItemizedValueDict} Dict of ALItemizedValues that would be
subtracted from a net total, like union dues or insurance premiums.
.times_per_year {float} A denominator of a year, like 12 for monthly, that
represents how frequently the income is earned
.employer {Individual} (Optional) Individual assumed to have a name and,
optionally, an address and phone.
.source {str} (Optional) The category of this item, like "public service".
"""
def init(self, *pargs, **kwargs):
super().init(*pargs, **kwargs)
self.ask_number = True
self.target_number = 12
month_names = [
"january", "february", "march", "april", "may", "june",
"july", "august", "september", "october", "november"
]
Comment on lines +26 to +29
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question as above. I'm just curious if this needs to be limited to chunking by month. Biweekly pay is a bit more common for most folks.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question. @CaroRob and I have been thinking about that discussion. I had hesitated to go beyond months since some seemed reluctant about getting even this granular, but if folks are open to that discussion, I am too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think about whether limiting it to months helps achieve any of the goals. It might, but I'm not sure. I think you discussed letting people avoid math. Monthly chunking doesn't seem like it would save any math.

Maybe letting users enter a date range? E.g., teachers usually have 9 month contracts, although they don't always have the choice to get paid over 9 months rather than 12.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made it more flexible. Will wait to fully adjust the demo till I get the go-ahead. Here's a sample file that shows the most basic application (you must answer that, yes, your job is seasonal). It's .txt and you have to have to change it to .yml because GitHub won't let me upload a .yml.

seasonal_income_demo.txt

for index, month_name in enumerate(month_names):
month = self.initializeObject(index, ALJob)
month.source = month_name
month.is_hourly = False
month.times_per_year = 1

if not hasattr(self, "employer"):
if hasattr(self, "employer_type"):
self.initializeAttribute("employer", self.employer_type)
else:
self.initializeAttribute("employer", Individual)

def employer_name_address_phone(self) -> str:
"""
Returns concatenation of employer name and, if they exist, employer
address and phone number.
"""
info_list = []
has_address = (
hasattr(self.employer.address, "address") and self.employer.address.address
)
has_number = (
hasattr(self.employer, "phone_number") and self.employer.phone_number
)
# Create a list so we can take advantage of `comma_list` instead
# of doing further fiddly list manipulation
if has_address:
info_list.append(self.employer.address.on_one_line())
if has_number:
info_list.append(self.employer.phone_number)
# If either exist, add a colon and the appropriate strings
if has_address or has_number:
return (
f"{ self.employer.name.full(middle='full') }: {comma_list( info_list )}"
)
return self.employer.name.full(middle="full")
---
comment: |
translation options:
- map dict/lookup from key to lang word. See https://github.com/nonprofittechy/docassemble-HousingCodeChecklist/blob/0cbfe02b29bbec66b8a2b925b36b3c67bb300e84/docassemble/HousingCodeChecklist/data/questions/language.yml#L41
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def find_package_data(where='.', package='', exclude=standard_exclude, exclude_d
url='https://suffolklitlab.org/docassemble-AssemblyLine-documentation/docs/framework/altoolbox',
packages=find_packages(),
namespace_packages=['docassemble'],
install_requires=['holidays>=0.27.1', 'pandas>=1.5.3'],
install_requires=['holidays>=0.27.1', 'pandas>=2.0.3'],
zip_safe=False,
package_data=find_package_data(where='docassemble/ALToolbox/', package='docassemble.ALToolbox'),
)
Expand Down
Loading