diff --git a/api/src/pcapi/core/finance/api.py b/api/src/pcapi/core/finance/api.py index fae59a52802..b57b3124149 100644 --- a/api/src/pcapi/core/finance/api.py +++ b/api/src/pcapi/core/finance/api.py @@ -54,6 +54,7 @@ import pcapi.core.educational.models as educational_models from pcapi.core.external import batch as push_notifications import pcapi.core.external.attributes.api as external_attributes_api +from pcapi.core.finance.enum import DepositType from pcapi.core.fraud import models as fraud_models from pcapi.core.history import api as history_api import pcapi.core.history.models as history_models @@ -2950,6 +2951,27 @@ def edit_reimbursement_rule( return rule +def compute_deposit_expiration_date(beneficiary: users_models.User, deposit_type: DepositType) -> datetime.datetime: + if deposit_type == DepositType.GRANT_17_18: + # If the feature is active, we use the new deposit expiration date computation (1 day before the 21st birthday) + return compute_deposit_expiration_date_v3(beneficiary) + + if deposit_type == DepositType.GRANT_15_17: + return compute_underage_deposit_expiration_datetime(beneficiary.birth_date) + + # deposit_type == DepositType.GRANT_18 + expiration_date = datetime.datetime.utcnow().date() + relativedelta(years=conf.GRANT_18_VALIDITY_IN_YEARS) + expiration_datetime = datetime.datetime.combine(expiration_date, datetime.time.max) + return expiration_datetime + + +def compute_deposit_expiration_date_v3(beneficiary: users_models.User) -> datetime.datetime: + if not beneficiary.birth_date: + raise ValueError(f"Beneficiary {beneficiary.id} has no birth date") + + return beneficiary.birth_date + relativedelta(years=21) + + def compute_underage_deposit_expiration_datetime(birth_date: datetime.date | None) -> datetime.datetime: if not birth_date: raise exceptions.UserNotGrantable("User has no validated birth date") @@ -2967,17 +2989,15 @@ def get_granted_deposit( return models.GrantedDeposit( amount=conf.GRANTED_DEPOSIT_AMOUNTS_FOR_UNDERAGE_BY_AGE[age_at_registration], - expiration_date=compute_underage_deposit_expiration_datetime(beneficiary.validatedBirthDate), + expiration_date=compute_deposit_expiration_date(beneficiary, deposit_type=DepositType.GRANT_15_17), type=models.DepositType.GRANT_15_17, version=1, ) if eligibility == users_models.EligibilityType.AGE18: - expiration_date = datetime.datetime.utcnow().date() + relativedelta(years=conf.GRANT_18_VALIDITY_IN_YEARS) - expiration_datetime = datetime.datetime.combine(expiration_date, datetime.time.max) return models.GrantedDeposit( amount=conf.GRANTED_DEPOSIT_AMOUNTS_FOR_18_BY_VERSION[2], - expiration_date=expiration_datetime, + expiration_date=compute_deposit_expiration_date(beneficiary, deposit_type=DepositType.GRANT_18), type=models.DepositType.GRANT_18, version=2, ) @@ -3029,6 +3049,48 @@ def create_deposit( deposit_source: str, eligibility: users_models.EligibilityType, age_at_registration: int | None = None, +) -> models.Deposit: + """Create a new deposit for the user if there is no deposit yet.""" + if feature.FeatureToggle.WIP_ENABLE_CREDIT_V3.is_active(): + return create_deposit_v3(beneficiary, deposit_source, eligibility, age_at_registration) + + return create_deposit_v2(beneficiary, deposit_source, eligibility, age_at_registration) + + +def create_deposit_v3( + beneficiary: users_models.User, + deposit_source: str, + eligibility: users_models.EligibilityType, + age_at_registration: int | None, +) -> models.Deposit: + """Create a new deposit for the user if there is no deposit yet.""" + if eligibility in [users_models.EligibilityType.AGE18, users_models.EligibilityType.UNDERAGE]: + return create_deposit_v2(beneficiary, deposit_source, eligibility, age_at_registration) + + if repository.deposit_exists_for_beneficiary_and_type(beneficiary, DepositType.GRANT_17_18): + raise exceptions.DepositTypeAlreadyGrantedException(DepositType.GRANT_17_18) + + if beneficiary.has_active_deposit: + raise exceptions.UserHasAlreadyActiveDeposit() + + deposit = models.Deposit( + version=1, + type=DepositType.GRANT_17_18, + amount=decimal.Decimal(0), + source=deposit_source, + user=beneficiary, + expirationDate=compute_deposit_expiration_date(beneficiary, DepositType.GRANT_17_18), + ) + db.session.add(deposit) + db.session.flush() + return deposit + + +def create_deposit_v2( + beneficiary: users_models.User, + deposit_source: str, + eligibility: users_models.EligibilityType, + age_at_registration: int | None = None, ) -> models.Deposit: """Create a new deposit for the user if there is no deposit yet.""" granted_deposit = get_granted_deposit( diff --git a/api/src/pcapi/core/finance/enum.py b/api/src/pcapi/core/finance/enum.py index 3f5122de7ac..eea9ccd6ffe 100644 --- a/api/src/pcapi/core/finance/enum.py +++ b/api/src/pcapi/core/finance/enum.py @@ -2,5 +2,7 @@ class DepositType(enum.Enum): + GRANT_17_18 = "GRANT_17_18" + # legacy deposit types that are present in the database GRANT_15_17 = "GRANT_15_17" GRANT_18 = "GRANT_18" diff --git a/api/src/pcapi/core/users/models.py b/api/src/pcapi/core/users/models.py index 207b1edb4e0..147cc857cc7 100644 --- a/api/src/pcapi/core/users/models.py +++ b/api/src/pcapi/core/users/models.py @@ -68,6 +68,8 @@ class UserRole(enum.Enum): class EligibilityType(enum.Enum): + AGE17_18 = "age-17-18" + # legacy eligibilities that are present in the database UNDERAGE = "underage" AGE18 = "age-18" diff --git a/api/tests/core/finance/test_api.py b/api/tests/core/finance/test_api.py index 0123e6ad20f..1ecb2585de8 100644 --- a/api/tests/core/finance/test_api.py +++ b/api/tests/core/finance/test_api.py @@ -4074,6 +4074,7 @@ def test_validation(self): api.edit_reimbursement_rule(rule, end_date=end) +@pytest.mark.features(WIP_ENABLE_CREDIT_V3=False) class CreateDepositTest: @time_machine.travel("2021-02-05 09:00:00") @pytest.mark.parametrize("age,expected_amount", [(15, Decimal(20)), (16, Decimal(30)), (17, Decimal(30))]) @@ -4341,6 +4342,61 @@ def test_cannot_create_twice_a_deposit_of_same_type(self): assert error.value.errors["user"] == ['Cet utilisateur a déjà été crédité de la subvention "GRANT_18".'] +@pytest.mark.features(WIP_ENABLE_CREDIT_V3=True) +class CreateDepositV3Test: + @time_machine.travel("2025-03-03") + def test_compute_deposit_expiration_date_v3(self): + user = users_models.User(validatedBirthDate=datetime.date(2007, 1, 1)) + assert user.age == 18 + + expiration_date = api.compute_deposit_expiration_date_v3(user) + + assert expiration_date == datetime.date(2028, 1, 1) + + @time_machine.travel("2025-03-03") + def test_create_deposit_v3(self): + user_v3 = users_factories.HonorStatementValidatedUserFactory(validatedBirthDate=datetime.date(2007, 1, 1)) + assert user_v3.age == 18 + + deposit_v3 = api.create_deposit(user_v3, "created by test", users_models.EligibilityType.AGE17_18) + assert deposit_v3.expirationDate == datetime.date(2028, 1, 1) + assert deposit_v3.type == models.DepositType.GRANT_17_18 + assert deposit_v3.amount == 0 + + @time_machine.travel("2025-03-03") + def test_create_deposit_age_18_even_when_ff_credit_v3_is_active(self): + user = users_factories.HonorStatementValidatedUserFactory(validatedBirthDate=datetime.date(2007, 1, 1)) + assert user.age == 18 + + deposit = api.create_deposit(user, "created by test", users_models.EligibilityType.AGE18) + + assert deposit.type == models.DepositType.GRANT_18 + assert deposit.amount == 300 + + @time_machine.travel("2025-03-03") + def test_17yo_becomes_17_18_when_ff_credit_v3_is_active(self): + user = users_factories.HonorStatementValidatedUserFactory(validatedBirthDate=datetime.date(2008, 1, 1)) + assert user.age == 17 + + deposit = api.create_deposit( + user, "created by test", users_models.EligibilityType.AGE17_18, age_at_registration=user.age + ) + + assert deposit.type == models.DepositType.GRANT_17_18 + + @time_machine.travel("2025-03-03") + def test_less_than_17_yo_keeps_underage_when_ff_credit_v3_is_active(self): + user = users_factories.HonorStatementValidatedUserFactory(validatedBirthDate=datetime.date(2009, 1, 1)) + assert user.age == 16 + + deposit = api.create_deposit( + user, "created by test", users_models.EligibilityType.UNDERAGE, age_at_registration=user.age + ) + + assert deposit.type == models.DepositType.GRANT_15_17 + assert deposit.amount == 30 + + class UserRecreditTest: @time_machine.travel("2021-07-01") @pytest.mark.parametrize( @@ -4888,17 +4944,15 @@ def test_notify_user_on_recredit(self): api.recredit_underage_users() assert user.deposit.amount == 50 - assert push_testing.requests[-1] == { - "can_be_asynchronously_retried": True, - "event_name": "recredited_account", - "event_payload": { - "deposit_amount": 50, - "deposit_type": "GRANT_15_17", - "deposits_count": 1, - "deposit_expiration_date": "2022-12-01T00:00:00", - }, - "user_id": user.id, - } + push_data = push_testing.requests[-1] + assert push_data["can_be_asynchronously_retried"] is True + assert push_data["event_name"] == "recredited_account" + assert push_data["user_id"] == user.id + payload = push_data["event_payload"] + assert payload["deposit_amount"] == 50 + assert payload["deposit_type"] == "GRANT_15_17" + assert payload["deposits_count"] == 1 + assert payload["deposit_expiration_date"] == user.deposit.expirationDate.isoformat() class ValidateFinanceIncidentTest: diff --git a/api/tests/routes/native/openapi_test.py b/api/tests/routes/native/openapi_test.py index 9446ee039e9..77ad426abd9 100644 --- a/api/tests/routes/native/openapi_test.py +++ b/api/tests/routes/native/openapi_test.py @@ -765,7 +765,7 @@ def test_public_api(client): }, "DepositType": { "description": "An enumeration.", - "enum": ["GRANT_15_17", "GRANT_18"], + "enum": ["GRANT_17_18", "GRANT_15_17", "GRANT_18"], "title": "DepositType", }, "DomainsCredit": { @@ -799,7 +799,7 @@ def test_public_api(client): }, "EligibilityType": { "description": "An enumeration.", - "enum": ["underage", "age-18"], + "enum": ["age-17-18", "underage", "age-18"], "title": "EligibilityType", }, "EmailChangeConfirmationResponse": {