diff --git a/docassemble/ALToolbox/al_income.py b/docassemble/ALToolbox/al_income.py index 6aa3e2d6..c6a278c5 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): """ @@ -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 @@ -958,12 +959,16 @@ 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. .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". + .intervals {ALItemizedIntervalList} Automatically exist, but they won't be used + 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. WARNING: Individual items in `.to_add` and `.to_subtract` should not be used directly. They should only be accessed through the filtering methods of @@ -985,8 +990,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) @@ -998,6 +1001,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 .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 @@ -1028,26 +1036,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 - # 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) @@ -1095,6 +1105,10 @@ def gross_total( total += self._item_value_per_times_per_year( value, times_per_year=times_per_year ) + 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 + ) return total def deduction_total( @@ -1126,6 +1140,10 @@ def deduction_total( total += self._item_value_per_times_per_year( value, times_per_year=times_per_year ) + 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 + ) return total def net_total( @@ -1196,7 +1214,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: @@ -1317,3 +1334,30 @@ def net_total( ) - self.deduction_total( times_per_year=times_per_year, source=source, exclude_source=exclude_source ) + + +class ALItemizedInterval(ALItemizedJob): + """ + """ + def init(self, *pargs, **kwargs): + # 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 ALItemizedIntervalList(ALItemizedJobList): + """ + """ + def init(self, *pargs, **kwargs): + kwargs['source'] = kwargs.get('source', "Pay stubs") + kwargs['object_type'] = kwargs.get('object_type', ALItemizedInterval) + super().init(*pargs, **kwargs) + \ No newline at end of file diff --git a/docassemble/ALToolbox/data/questions/al_income.yml b/docassemble/ALToolbox/data/questions/al_income.yml index 60d5e762..8416ab2c 100644 --- a/docassemble/ALToolbox/data/questions/al_income.yml +++ b/docassemble/ALToolbox/data/questions/al_income.yml @@ -173,17 +173,34 @@ 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 +depends on: + - x.has_inconsistent_income +code: | + # Still to be fleshed out + 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 + 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 +284,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: | 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 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'), )