Skip to content

Commit

Permalink
(PC-31244)[API] feat: recredit underage users of all their missing re…
Browse files Browse the repository at this point in the history
…credits

Recrediting users of all their missing recredits makes the recredit job
more robust.
  • Loading branch information
cepehang committed Sep 2, 2024
1 parent 2d8379b commit e1ccece
Show file tree
Hide file tree
Showing 2 changed files with 90 additions and 25 deletions.
70 changes: 46 additions & 24 deletions api/src/pcapi/core/finance/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2804,16 +2804,42 @@ def get_granted_deposit(
return None


def _recredit_user(user: users_models.User, deposit: models.Deposit) -> models.Recredit | None:
if not user.age:
def _recredit_user(user: users_models.User) -> models.Recredit | None:
"""
Recredits the user of all their missing recredits.
:return: The most recent recredit.
"""
deposit = user.deposit
if deposit is None or user.age is None:
return None

min_age_between_deposit_and_now = _get_known_age_at_deposit(user)
if min_age_between_deposit_and_now is None or user.age < min_age_between_deposit_and_now:
# this can happen when the beneficiary activates their credit at 17, and their age is
# set back to 15 years old, either through manual backoffice action or automatic identity
# provider check
min_age_between_deposit_and_now = user.age

latest_recredit: models.Recredit | None = None
for age in range(min_age_between_deposit_and_now, user.age + 1):
if not _can_be_recredited(user, age):
continue
latest_recredit = _recredit_deposit(deposit, age)

return latest_recredit


def _recredit_deposit(deposit: models.Deposit, age: int) -> models.Recredit:
recredit = models.Recredit(
deposit=deposit,
amount=conf.RECREDIT_TYPE_AMOUNT_MAPPING[conf.RECREDIT_TYPE_AGE_MAPPING[user.age]],
recreditType=conf.RECREDIT_TYPE_AGE_MAPPING[user.age],
amount=conf.RECREDIT_TYPE_AMOUNT_MAPPING[conf.RECREDIT_TYPE_AGE_MAPPING[age]],
recreditType=conf.RECREDIT_TYPE_AGE_MAPPING[age],
)
deposit.amount += recredit.amount

db.session.add(recredit)
db.session.flush()

return recredit


Expand Down Expand Up @@ -2850,24 +2876,15 @@ def create_deposit(
db.session.add(deposit)
db.session.flush()

# Edge-cases: Validation of the registration occurred over a birthday
# Edge-case: Validation of the registration occurred over one or two birthdays
# Then we need to add recredit to compensate
if (
eligibility == users_models.EligibilityType.UNDERAGE
and _can_be_recredited(beneficiary)
and beneficiary.age
and age_at_registration
):
recredit = _recredit_user(beneficiary, deposit)
if recredit:
# Rare edge-case: Validation is longer than a year and started when user was 15
if beneficiary.age == age_at_registration + 2:
# User will get grant from registration age and recredit from current age
# Therefore missing recredit is 16's one.
additional_amount = conf.GRANTED_DEPOSIT_AMOUNTS_FOR_UNDERAGE_BY_AGE[16]
deposit.amount += additional_amount

db.session.add(recredit)
_recredit_user(beneficiary)

return deposit

Expand All @@ -2884,11 +2901,13 @@ def expire_current_deposit_for_user(user: users_models.User) -> None:
)


def _can_be_recredited(user: users_models.User) -> bool:
def _can_be_recredited(user: users_models.User, age: int | None = None) -> bool:
if age is None:
age = user.age
return (
user.age in conf.RECREDIT_TYPE_AGE_MAPPING
age in conf.RECREDIT_TYPE_AGE_MAPPING
and _has_celebrated_birthday_since_credit_or_registration(user)
and not _has_been_recredited(user)
and not _has_been_recredited(user, age)
)


Expand All @@ -2909,28 +2928,31 @@ def _has_celebrated_birthday_since_credit_or_registration(user: users_models.Use
return first_registration_datetime.date() < latest_birthday_date


def _has_been_recredited(user: users_models.User) -> bool:
if user.age is None:
def _has_been_recredited(user: users_models.User, age: int | None = None) -> bool:
if age is None:
age = user.age

if age is None:
logger.error("Trying to check recredit for user that has no age", extra={"user_id": user.id})
return False

if user.deposit is None:
return False

known_age_at_deposit = _get_known_age_at_deposit(user)
if known_age_at_deposit == user.age:
if known_age_at_deposit == age:
return True

if len(user.deposit.recredits) == 0:
return False

has_been_recredited = conf.RECREDIT_TYPE_AGE_MAPPING[user.age] in [
has_been_recredited = conf.RECREDIT_TYPE_AGE_MAPPING[age] in [
recredit.recreditType for recredit in user.deposit.recredits
]
if has_been_recredited:
return True

if user.age == 16:
if age == 16:
# This condition handles the following edge case:
# - User started the activation workflow at 15 and finished it at 17.
# This used to create a deposit holding the total amount from 15 to 17 years old without creating
Expand Down Expand Up @@ -3058,7 +3080,7 @@ def recredit_underage_users() -> None:
with transaction():
for user in users_to_recredit:
try:
recredit = _recredit_user(user, user.deposit)
recredit = _recredit_user(user)
if recredit: # recredit will be None is user's age is also None
users_and_recredit_amounts.append((user, recredit.amount))
user.recreditAmountToShow = recredit.amount if recredit.amount > 0 else None
Expand Down
45 changes: 44 additions & 1 deletion api/tests/core/finance/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3613,7 +3613,9 @@ def test_create_underage_deposit_and_recredit_after_validation(self, age, expect
status=fraud_models.FraudCheckStatus.OK,
type=fraud_models.FraudCheckType.EDUCONNECT,
eligibilityType=users_models.EligibilityType.UNDERAGE,
resultContent=fraud_factories.EduconnectContentFactory(registration_datetime=datetime.datetime.utcnow()),
resultContent=fraud_factories.EduconnectContentFactory(
registration_datetime=datetime.datetime.utcnow(), age=age
),
)

# Deposit is created right after the validation of the registration
Expand Down Expand Up @@ -4320,6 +4322,47 @@ def test_recredit_when_account_activated_on_the_birthday(self):
assert user.deposit.amount == 30
assert user.recreditAmountToShow is None

def test_recredit_with_two_birthdays_since_registration(self):
seventeen_years_ago = datetime.datetime.utcnow() - relativedelta(years=17)
beneficiary = users_factories.UserFactory(
validatedBirthDate=seventeen_years_ago, dateOfBirth=seventeen_years_ago.date()
)
beneficiary.add_underage_beneficiary_role()
thirteen_months_ago = datetime.datetime.utcnow() - relativedelta(years=1, months=1)
fraud_factories.BeneficiaryFraudCheckFactory(
user=beneficiary,
status=fraud_models.FraudCheckStatus.STARTED,
type=fraud_models.FraudCheckType.EDUCONNECT,
eligibilityType=users_models.EligibilityType.UNDERAGE,
resultContent=fraud_factories.EduconnectContentFactory(
birth_date=seventeen_years_ago,
registration_datetime=thirteen_months_ago, # beneficiary is 15 during registration
),
)
granted_deposit = api.get_granted_deposit(
beneficiary,
users_models.EligibilityType.UNDERAGE,
age_at_registration=15,
)
deposit = models.Deposit(
version=granted_deposit.version,
type=granted_deposit.type,
amount=granted_deposit.amount,
source="test",
user=beneficiary,
expirationDate=granted_deposit.expiration_date,
)
db.session.add(deposit)
db.session.flush()

api.recredit_underage_users()

assert set(recredit.recreditType for recredit in deposit.recredits) == {
models.RecreditType.RECREDIT_16,
models.RecreditType.RECREDIT_17,
}
assert len(deposit.recredits) == 2

def test_notify_user_on_recredit(self):
with time_machine.travel("2020-05-01"):
user = users_factories.UnderageBeneficiaryFactory(subscription_age=15)
Expand Down

0 comments on commit e1ccece

Please sign in to comment.