From 93297a9db2ccb566f4d0e19581f43bf62fa135f6 Mon Sep 17 00:00:00 2001 From: Anonymous User <52798256+plocket@users.noreply.github.com> Date: Mon, 9 Oct 2023 18:46:31 -0400 Subject: [PATCH 1/5] Fix `.is_hourly` required --- docassemble/ALToolbox/al_income.py | 7 ++++--- setup.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docassemble/ALToolbox/al_income.py b/docassemble/ALToolbox/al_income.py index 6aa3e2d6..e126cfb9 100644 --- a/docassemble/ALToolbox/al_income.py +++ b/docassemble/ALToolbox/al_income.py @@ -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 @@ -960,6 +961,8 @@ class ALItemizedJob(DAObject): 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. + .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". @@ -985,8 +988,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) @@ -1043,7 +1044,7 @@ def _item_value_per_times_per_year( # 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 diff --git a/setup.py b/setup.py index 0d5cce1c..6ede507c 100644 --- a/setup.py +++ b/setup.py @@ -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'), ) From 32e574db3be336b3094460a8bd9d3d643e483c23 Mon Sep 17 00:00:00 2001 From: Anonymous User <52798256+plocket@users.noreply.github.com> Date: Wed, 11 Oct 2023 09:49:04 -0400 Subject: [PATCH 2/5] First draft of seasonal classes. Fixes `hours_per_period`. --- docassemble/ALToolbox/al_income.py | 87 +++++++++++++++---- .../data/questions/al_income_demo.yml | 60 +++++++++++++ 2 files changed, 128 insertions(+), 19 deletions(-) diff --git a/docassemble/ALToolbox/al_income.py b/docassemble/ALToolbox/al_income.py index e126cfb9..49112fae 100644 --- a/docassemble/ALToolbox/al_income.py +++ b/docassemble/ALToolbox/al_income.py @@ -36,8 +36,8 @@ "ALVehicleList", "ALSimpleValue", "ALSimpleValueList", - "ALItemizedValue", - "ALItemizedValueDict", + "ALItemizedValue", # Rationale for exporting this? + "ALItemizedValueDict", # Rationale for exporting this? "ALItemizedJob", "ALItemizedJobList", ] @@ -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): """ @@ -959,14 +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 + 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 @@ -999,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 @@ -1029,19 +1038,6 @@ 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 - # Both the job and the item itself need to be hourly to be # calculated as hourly is_hourly = hasattr(self, "is_hourly") and self.is_hourly and hasattr(item, "is_hourly") and item.is_hourly @@ -1049,6 +1045,21 @@ def _item_value_per_times_per_year( # 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) @@ -1096,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( @@ -1318,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", + "july", "august", "september", "october", "november" + ] + for month_name in month_names: + month = self.appendObject(source=month_name) + \ No newline at end of file diff --git a/docassemble/ALToolbox/data/questions/al_income_demo.yml b/docassemble/ALToolbox/data/questions/al_income_demo.yml index 2a98baaf..8ff0deaa 100644 --- a/docassemble/ALToolbox/data/questions/al_income_demo.yml +++ b/docassemble/ALToolbox/data/questions/al_income_demo.yml @@ -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" + ] + 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 From 10ee3a0bec07e4a14dbcd61cabe89743e47aab37 Mon Sep 17 00:00:00 2001 From: Anonymous User <52798256+plocket@users.noreply.github.com> Date: Fri, 24 Nov 2023 15:37:34 -0500 Subject: [PATCH 3/5] Switch to "interval" instead of "monthly", allow other gathering methods, add deduction math --- docassemble/ALToolbox/al_income.py | 42 ++++++++++++++---------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/docassemble/ALToolbox/al_income.py b/docassemble/ALToolbox/al_income.py index 49112fae..dfeaee40 100644 --- a/docassemble/ALToolbox/al_income.py +++ b/docassemble/ALToolbox/al_income.py @@ -966,7 +966,7 @@ class ALItemizedJob(DAObject): .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". - .months {ALItemizedJobMonthList} Automatically exist, but they won't be used + .intervals {ALItemizedIntervalList} Automatically exist, but they won't be used 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 @@ -1004,10 +1004,10 @@ def init(self, *pargs, **kwargs): 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) + # Every non-month job will have .intervals, though not all jobs will use them + add_intervals = kwargs.get('add_intervals', True) + if add_intervals: + self.initializeAttribute("intervals", ALItemizedIntervalList) def _item_value_per_times_per_year( self, item: ALItemizedValue, times_per_year: float = 1 @@ -1108,7 +1108,7 @@ def gross_total( value, times_per_year=times_per_year ) if hasattr(self, 'is_seasonal') and self.is_seasonal: - total += self.months.gross_total( + total += self.intervals.gross_total( times_per_year=times_per_year, source=source, exclude_source=exclude_source ) return total @@ -1142,6 +1142,10 @@ def deduction_total( total += self._item_value_per_times_per_year( value, times_per_year=times_per_year ) + if hasattr(self, 'intervals'): + total += self.intervals.deduction_total( + times_per_year=times_per_year, source=source, exclude_source=exclude_source + ) return total def net_total( @@ -1212,7 +1216,6 @@ class ALItemizedJobList(DAList): Represents a list of ALItemizedJobs that can have both payments and money out. This is a less common way of reporting income. """ - def init(self, *pargs, **kwargs): super().init(*pargs, **kwargs) if not hasattr(self, "source") or self.source is None: @@ -1335,35 +1338,28 @@ def net_total( ) -class ALItemizedJobMonth(ALItemizedJob): +class ALItemizedInterval(ALItemizedJob): """ """ def init(self, *pargs, **kwargs): - kwargs['add_months'] = kwargs.get('add_months', False) + # TODO: Do we need to allow intervals to be hourly? kwargs['is_hourly'] = kwargs.get('is_hourly', False) + # Each interval happens just once per year. E.g. "january" happens once per year. kwargs['times_per_year'] = kwargs.get('times_per_year', 1) + + # Don't add intervals to an object that already has intervals + kwargs['add_intervals'] = kwargs.get('add_intervals', False) super().init(*pargs, **kwargs) self.to_add.there_are_any = True self.to_subtract.there_are_any = True -class ALItemizedJobMonthList(ALItemizedJobList): +class ALItemizedIntervalList(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) + kwargs['source'] = kwargs.get('source', "Pay stubs") + kwargs['object_type'] = kwargs.get('object_type', ALItemizedInterval) super().init(*pargs, **kwargs) - - month_names = [ - "january", "february", "march", "april", "may", "june", - "july", "august", "september", "october", "november" - ] - for month_name in month_names: - month = self.appendObject(source=month_name) \ No newline at end of file From 558d7c3446cb6ae694a8c5b1fb11fcb67ea32315 Mon Sep 17 00:00:00 2001 From: Anonymous User <52798256+plocket@users.noreply.github.com> Date: Fri, 24 Nov 2023 16:24:25 -0500 Subject: [PATCH 4/5] Avoid automatically gathering intervals. Questions undetermined. --- docassemble/ALToolbox/al_income.py | 8 ++-- .../ALToolbox/data/questions/al_income.yml | 41 +++++++++++++++---- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/docassemble/ALToolbox/al_income.py b/docassemble/ALToolbox/al_income.py index dfeaee40..c6a278c5 100644 --- a/docassemble/ALToolbox/al_income.py +++ b/docassemble/ALToolbox/al_income.py @@ -961,13 +961,11 @@ class ALItemizedJob(DAObject): user earns on an hourly basis, rather than for the full time 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". .intervals {ALItemizedIntervalList} Automatically exist, but they won't be used - unless the `is_seasonal` property is set to True. Then the give monthly + unless the `has_inconsistent_income` 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. @@ -1107,7 +1105,7 @@ 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: + if hasattr(self, 'has_inconsistent_income') and self.has_inconsistent_income: total += self.intervals.gross_total( times_per_year=times_per_year, source=source, exclude_source=exclude_source ) @@ -1142,7 +1140,7 @@ def deduction_total( total += self._item_value_per_times_per_year( value, times_per_year=times_per_year ) - if hasattr(self, 'intervals'): + if hasattr(self, 'has_inconsistent_income') and self.has_inconsistent_income: total += self.intervals.deduction_total( times_per_year=times_per_year, source=source, exclude_source=exclude_source ) diff --git a/docassemble/ALToolbox/data/questions/al_income.yml b/docassemble/ALToolbox/data/questions/al_income.yml index 60d5e762..7061bd07 100644 --- a/docassemble/ALToolbox/data/questions/al_income.yml +++ b/docassemble/ALToolbox/data/questions/al_income.yml @@ -173,17 +173,32 @@ code: | x.is_self_employed # NOTE: if `is_self_employed`, you need to set this yourself x.employer.name.first - x.times_per_year - x.to_add.complete_attribute = 'complete' - x.to_subtract.complete_attribute = 'complete' - if x.is_part_time: - x.to_add["part time"].is_hourly = x.is_hourly - else: - x.to_add["full time"].is_hourly = x.is_hourly - x.to_add.gather() - x.to_subtract.gather() + x.get_income x.complete = True --- +generic object: ALItemizedJob +code: | + # Still to be fleshed out + if hasattr(x, 'has_inconsistent_income'): + x.intervals.there_are_any = x.has_inconsistent_income + x.times_per_year = 1 + x.to_add.there_are_any = False + x.to_add.gathered = True + x.to_subtract.there_are_any = False + x.to_subtract.gathered = True + x.intervals.gather() + else: + x.times_per_year + x.to_add.complete_attribute = 'complete' + x.to_subtract.complete_attribute = 'complete' + if x.is_part_time: + x.to_add["part time"].is_hourly = x.is_hourly + else: + x.to_add["full time"].is_hourly = x.is_hourly + x.to_add.gather() + x.to_subtract.gather() + x.get_income = True +--- generic object: ALIncome code: | x.source @@ -267,6 +282,14 @@ code: | x.employer.phone = "" x.employer.address.address = "" --- +id: itemized job pay intervals +generic object: ALItemizedJob +question: | + How would you describe your pay as a ${ x.source } +fields: + - Does your pay change a lot? For example, is it different in different months or seasons?: x.has_inconsistent_income + datatype: yesnoradio +--- id: itemized job line items generic object: ALItemizedJob question: | From 82d9a4500d20086b95468060303ce62fcbaf0767 Mon Sep 17 00:00:00 2001 From: Anonymous User <52798256+plocket@users.noreply.github.com> Date: Fri, 24 Nov 2023 16:27:22 -0500 Subject: [PATCH 5/5] Allow dev to set whether to ask "seasonal" question (name change needed) --- docassemble/ALToolbox/data/questions/al_income.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docassemble/ALToolbox/data/questions/al_income.yml b/docassemble/ALToolbox/data/questions/al_income.yml index 7061bd07..8416ab2c 100644 --- a/docassemble/ALToolbox/data/questions/al_income.yml +++ b/docassemble/ALToolbox/data/questions/al_income.yml @@ -177,9 +177,11 @@ code: | x.complete = True --- generic object: ALItemizedJob +depends on: + - x.has_inconsistent_income code: | # Still to be fleshed out - if hasattr(x, 'has_inconsistent_income'): + if hasattr(x, 'ask_seasonal') and x.ask_seasonal: x.intervals.there_are_any = x.has_inconsistent_income x.times_per_year = 1 x.to_add.there_are_any = False