Repository: cal-itp/benefits

Issue:
Enhance EligibilityType model with expiration-related field(s)
With the coming Low-income / CalFresh enrollment flow, we'll need a way to display certain information and apply logic in-code for discounts that expire, e.g.

* Does the discount / eligibility type expire at all?
* How long after enrollment does the discount expire?
* How long before expiration can a user re-enroll?

For CalFresh, this will be used in a number of places, e.g.

* In #1877, we'll calculate and send an expiration date with the group enrollment
* In #1919, we'll display the date of expiration
* In #1921, we'll display the date of expiration and the date at which re-enrollment can occur

We need a relatively simple way to make these decisions without (ideally) having to hardcode this logic into the templates / views, and to make this more modular for future, non-CalFresh flows that expire (e.g. students, enhancements to Agency cards).

## Acceptance criteria

The following fields are added to the `EligibilityType` model:

- [x] `supports_expiration`: a [`BooleanField`](https://docs.djangoproject.com/en/5.0/ref/models/fields/#booleanfield) indicating if the `EligibiltyType` supports expiration, default to `False`
- [x] `expiration_days`: a [`PositiveSmallIntegerField`](https://docs.djangoproject.com/en/5.0/ref/models/fields/#positivesmallintegerfield) indicating how many days after enrollment the associated discount expires, allow `null` but requiring a value `> 0` when `supports_expiration == True`; see Django docs on [Cleaning and validating fields that depend on each other](https://docs.djangoproject.com/en/5.0/ref/forms/validation/#cleaning-and-validating-fields-that-depend-on-each-other) and [Validating model instances](https://docs.djangoproject.com/en/5.0/ref/models/instances/#validating-objects)
- [x] `expiration_reenrollment_days`: a [`PositiveSmallIntegerField`](https://docs.djangoproject.com/en/5.0/ref/models/fields/#positivesmallintegerfield) indicating how many days _before_ expiration a user is allowed to re-enroll for the discount, allow `null` but requiring a value `> 0` when `supports_expiration == True`; see Django docs on [Cleaning and validating fields that depend on each other](https://docs.djangoproject.com/en/5.0/ref/forms/validation/#cleaning-and-validating-fields-that-depend-on-each-other) and [Validating model instances](https://docs.djangoproject.com/en/5.0/ref/models/instances/#validating-objects)


Discussion:

Resolving patch:
diff --git a/benefits/core/migrations/0003_eligibilitytype_expiration.py b/benefits/core/migrations/0003_eligibilitytype_expiration.py
new file mode 100644
--- /dev/null
+++ b/benefits/core/migrations/0003_eligibilitytype_expiration.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.0.2 on 2024-03-14 20:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("core", "0002_paymentprocessor_backoffice_api"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="eligibilitytype",
+            name="expiration_days",
+            field=models.PositiveSmallIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name="eligibilitytype",
+            name="expiration_reenrollment_days",
+            field=models.PositiveSmallIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name="eligibilitytype",
+            name="supports_expiration",
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/benefits/core/models.py b/benefits/core/models.py
--- a/benefits/core/models.py
+++ b/benefits/core/models.py
@@ -7,6 +7,7 @@
 import logging
 
 from django.conf import settings
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.urls import reverse
 
@@ -109,6 +110,9 @@ class EligibilityType(models.Model):
     name = models.TextField()
     label = models.TextField()
     group_id = models.TextField()
+    supports_expiration = models.BooleanField(default=False)
+    expiration_days = models.PositiveSmallIntegerField(null=True, blank=True)
+    expiration_reenrollment_days = models.PositiveSmallIntegerField(null=True, blank=True)
 
     def __str__(self):
         return self.label
@@ -132,6 +136,22 @@ def get_names(eligibility_types):
             eligibility_types = [eligibility_types]
         return [t.name for t in eligibility_types if isinstance(t, EligibilityType)]
 
+    def clean(self):
+        supports_expiration = self.supports_expiration
+        expiration_days = self.expiration_days
+        expiration_reenrollment_days = self.expiration_reenrollment_days
+
+        if supports_expiration:
+            errors = {}
+            message = "When support_expiration is True, this value must be greater than 0."
+            if expiration_days is None or expiration_days <= 0:
+                errors.update(expiration_days=ValidationError(message))
+            if expiration_reenrollment_days is None or expiration_reenrollment_days <= 0:
+                errors.update(expiration_reenrollment_days=ValidationError(message))
+
+            if errors:
+                raise ValidationError(errors)
+
 
 class EligibilityVerifier(models.Model):
     """An entity that verifies eligibility."""


Test patch:
diff --git a/tests/pytest/core/test_models.py b/tests/pytest/core/test_models.py
--- a/tests/pytest/core/test_models.py
+++ b/tests/pytest/core/test_models.py
@@ -1,4 +1,5 @@
 from django.conf import settings
+from django.core.exceptions import ValidationError
 
 import pytest
 
@@ -12,6 +13,45 @@ def mock_requests_get_pem_data(mocker):
     return mocker.patch("benefits.core.models.requests.get", return_value=mocker.Mock(text="PEM text"))
 
 
+@pytest.fixture
+def model_EligibilityType_does_not_support_expiration(model_EligibilityType):
+    model_EligibilityType.supports_expiration = False
+    model_EligibilityType.expiration_days = 0
+    model_EligibilityType.save()
+
+    return model_EligibilityType
+
+
+@pytest.fixture
+def model_EligibilityType_zero_expiration_days(model_EligibilityType):
+    model_EligibilityType.supports_expiration = True
+    model_EligibilityType.expiration_days = 0
+    model_EligibilityType.expiration_reenrollment_days = 14
+    model_EligibilityType.save()
+
+    return model_EligibilityType
+
+
+@pytest.fixture
+def model_EligibilityType_zero_expiration_reenrollment_days(model_EligibilityType):
+    model_EligibilityType.supports_expiration = True
+    model_EligibilityType.expiration_days = 14
+    model_EligibilityType.expiration_reenrollment_days = 0
+    model_EligibilityType.save()
+
+    return model_EligibilityType
+
+
+@pytest.fixture
+def model_EligibilityType_supports_expiration(model_EligibilityType):
+    model_EligibilityType.supports_expiration = True
+    model_EligibilityType.expiration_days = 365
+    model_EligibilityType.expiration_reenrollment_days = 14
+    model_EligibilityType.save()
+
+    return model_EligibilityType
+
+
 def test_SecretNameField_init():
     field = SecretNameField()
 
@@ -180,6 +220,41 @@ def test_EligibilityVerifier_str(model_EligibilityVerifier):
     assert str(model_EligibilityVerifier) == model_EligibilityVerifier.name
 
 
+@pytest.mark.django_db
+def test_EligibilityType_supports_expiration_False(model_EligibilityType_does_not_support_expiration):
+    # test will fail if any error is raised
+    model_EligibilityType_does_not_support_expiration.full_clean()
+
+
+@pytest.mark.django_db
+def test_EligibilityType_zero_expiration_days(model_EligibilityType_zero_expiration_days):
+    with pytest.raises(ValidationError) as exception_info:
+        model_EligibilityType_zero_expiration_days.full_clean()
+
+    error_dict = exception_info.value.error_dict
+    assert len(error_dict["expiration_days"]) == 1
+    assert error_dict["expiration_days"][0].message == "When support_expiration is True, this value must be greater than 0."
+
+
+@pytest.mark.django_db
+def test_EligibilityType_zero_expiration_reenrollment_days(model_EligibilityType_zero_expiration_reenrollment_days):
+    with pytest.raises(ValidationError) as exception_info:
+        model_EligibilityType_zero_expiration_reenrollment_days.full_clean()
+
+    error_dict = exception_info.value.error_dict
+    assert len(error_dict["expiration_reenrollment_days"]) == 1
+    assert (
+        error_dict["expiration_reenrollment_days"][0].message
+        == "When support_expiration is True, this value must be greater than 0."
+    )
+
+
+@pytest.mark.django_db
+def test_EligibilityType_supports_expiration(model_EligibilityType_supports_expiration):
+    # test will fail if any error is raised
+    model_EligibilityType_supports_expiration.full_clean()
+
+
 class SampleFormClass:
     """A class for testing EligibilityVerifier form references."""