diff --git a/commcare_connect/opportunity/migrations/0059_completedwork_payment_date.py b/commcare_connect/opportunity/migrations/0059_completedwork_payment_date.py new file mode 100644 index 00000000..f1b9c862 --- /dev/null +++ b/commcare_connect/opportunity/migrations/0059_completedwork_payment_date.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.5 on 2024-10-03 02:33 + +from django.db import migrations, models +from django.db import transaction + +from commcare_connect.opportunity.models import OpportunityAccess +from commcare_connect.opportunity.visit_import import update_work_payment_date + + +@transaction.atomic +def update_paid_date_from_payments(apps, schema_editor): + accesses = OpportunityAccess.objects.all() + for access in accesses: + update_work_payment_date(access) + + +class Migration(migrations.Migration): + dependencies = [ + ("opportunity", "0058_paymentinvoice_payment_invoice"), + ] + + operations = [ + migrations.AddField( + model_name="completedwork", + name="payment_date", + field=models.DateTimeField(null=True), + ), + migrations.RunPython(update_paid_date_from_payments, migrations.RunPython.noop), + ] diff --git a/commcare_connect/opportunity/models.py b/commcare_connect/opportunity/models.py index 87d970c3..db74f16e 100644 --- a/commcare_connect/opportunity/models.py +++ b/commcare_connect/opportunity/models.py @@ -434,6 +434,7 @@ class CompletedWork(models.Model): entity_name = models.CharField(max_length=255, null=True, blank=True) reason = models.CharField(max_length=300, null=True, blank=True) status_modified_date = models.DateTimeField(null=True) + payment_date = models.DateTimeField(null=True) def __init__(self, *args, **kwargs): self.status = CompletedWorkStatus.incomplete diff --git a/commcare_connect/opportunity/tests/factories.py b/commcare_connect/opportunity/tests/factories.py index a1d2bd15..44d5b5a1 100644 --- a/commcare_connect/opportunity/tests/factories.py +++ b/commcare_connect/opportunity/tests/factories.py @@ -216,3 +216,12 @@ class DeliveryTypeFactory(DjangoModelFactory): class Meta: model = "opportunity.DeliveryType" + + +class PaymentFactory(DjangoModelFactory): + opportunity_access = SubFactory(OpportunityAccessFactory) + amount = Faker("pyint", min_value=1, max_value=10000) + date_paid = Faker("past_date") + + class Meta: + model = "opportunity.Payment" diff --git a/commcare_connect/opportunity/tests/test_visit_import.py b/commcare_connect/opportunity/tests/test_visit_import.py index 85ac8ff8..4c5f10ee 100644 --- a/commcare_connect/opportunity/tests/test_visit_import.py +++ b/commcare_connect/opportunity/tests/test_visit_import.py @@ -1,9 +1,11 @@ import random import re +from datetime import timedelta from decimal import Decimal from itertools import chain import pytest +from django.utils import timezone from django.utils.timezone import now from tablib import Dataset @@ -24,9 +26,11 @@ CompletedWorkFactory, DeliverUnitFactory, OpportunityAccessFactory, + PaymentFactory, PaymentUnitFactory, UserVisitFactory, ) +from commcare_connect.opportunity.utils.completed_work import update_work_payment_date from commcare_connect.opportunity.visit_import import ( ImportException, VisitData, @@ -384,3 +388,154 @@ def test_bulk_update_catchments(opportunity, dataset, new_catchments, old_catchm updated_catchment.longitude == catchment.longitude + longitude_change ), f"Longitude not updated correctly for catchment {catchment.id}" assert updated_catchment.active, f"Active status not updated correctly for catchment {catchment.id}" + + +def prepare_opportunity_payment_test_data(opportunity): + user = MobileUserFactory() + access = OpportunityAccessFactory(opportunity=opportunity, user=user, accepted=True) + + payment_units = [ + PaymentUnitFactory(opportunity=opportunity, amount=100), + PaymentUnitFactory(opportunity=opportunity, amount=150), + PaymentUnitFactory(opportunity=opportunity, amount=200), + ] + + for payment_unit in payment_units: + DeliverUnitFactory.create_batch(2, payment_unit=payment_unit, app=opportunity.deliver_app, optional=False) + + completed_works = [] + for payment_unit in payment_units: + completed_work = CompletedWorkFactory( + opportunity_access=access, + payment_unit=payment_unit, + status=CompletedWorkStatus.approved.value, + payment_date=None, + ) + completed_works.append(completed_work) + for deliver_unit in payment_unit.deliver_units.all(): + UserVisitFactory( + opportunity=opportunity, + user=user, + deliver_unit=deliver_unit, + status=VisitValidationStatus.approved.value, + opportunity_access=access, + completed_work=completed_work, + ) + return user, access, payment_units, completed_works + + +@pytest.mark.django_db +def test_update_work_payment_date_partially(opportunity): + user, access, payment_units, completed_works = prepare_opportunity_payment_test_data(opportunity) + + payment_dates = [ + timezone.now() - timedelta(5), + timezone.now() - timedelta(3), + timezone.now() - timedelta(1), + ] + for date in payment_dates: + PaymentFactory(opportunity_access=access, amount=100, date_paid=date) + + update_work_payment_date(access) + + assert ( + get_assignable_completed_work_count(access) + == CompletedWork.objects.filter(opportunity_access=access, payment_date__isnull=False).count() + ) + + +@pytest.mark.django_db +def test_update_work_payment_date_fully(opportunity): + user, access, payment_units, completed_works = prepare_opportunity_payment_test_data(opportunity) + + payment_dates = [ + timezone.now() - timedelta(days=5), + timezone.now() - timedelta(days=3), + timezone.now() - timedelta(days=1), + ] + amounts = [100, 150, 200] + for date, amount in zip(payment_dates, amounts): + PaymentFactory(opportunity_access=access, amount=amount, date_paid=date) + + update_work_payment_date(access) + + assert CompletedWork.objects.filter( + opportunity_access=access, payment_date__isnull=False + ).count() == get_assignable_completed_work_count(access) + + +@pytest.mark.django_db +def test_update_work_payment_date_with_precise_dates(opportunity): + user = MobileUserFactory() + access = OpportunityAccessFactory(opportunity=opportunity, user=user, accepted=True) + + payment_units = [ + PaymentUnitFactory(opportunity=opportunity, amount=5), + PaymentUnitFactory(opportunity=opportunity, amount=5), + ] + + for payment_unit in payment_units: + DeliverUnitFactory.create_batch(2, payment_unit=payment_unit, app=opportunity.deliver_app, optional=False) + + completed_work_1 = CompletedWorkFactory( + opportunity_access=access, + payment_unit=payment_units[0], + status=CompletedWorkStatus.approved.value, + payment_date=None, + ) + + completed_work_2 = CompletedWorkFactory( + opportunity_access=access, + payment_unit=payment_units[1], + status=CompletedWorkStatus.approved.value, + payment_date=None, + ) + + create_user_visits_for_completed_work(opportunity, user, access, payment_units[0], completed_work_1) + create_user_visits_for_completed_work(opportunity, user, access, payment_units[1], completed_work_2) + + now = timezone.now() + + payment_1 = PaymentFactory(opportunity_access=access, amount=7) + payment_2 = PaymentFactory(opportunity_access=access, amount=3) + + payment_1.date_paid = now - timedelta(3) + payment_2.date_paid = now - timedelta(1) + payment_1.save() + payment_2.save() + + payment_1.refresh_from_db() + payment_2.refresh_from_db() + + update_work_payment_date(access) + + completed_work_1.refresh_from_db() + completed_work_2.refresh_from_db() + + assert completed_work_1.payment_date == payment_1.date_paid + + assert completed_work_2.payment_date == payment_2.date_paid + + +def create_user_visits_for_completed_work(opportunity, user, access, payment_unit, completed_work): + for deliver_unit in payment_unit.deliver_units.all(): + UserVisitFactory( + opportunity=opportunity, + user=user, + deliver_unit=deliver_unit, + status=VisitValidationStatus.approved.value, + opportunity_access=access, + completed_work=completed_work, + ) + + +def get_assignable_completed_work_count(access: OpportunityAccess) -> int: + total_available_amount = sum(payment.amount for payment in Payment.objects.filter(opportunity_access=access)) + total_assigned_count = 0 + completed_works = CompletedWork.objects.filter(opportunity_access=access) + for completed_work in completed_works: + if total_available_amount >= completed_work.payment_accrued: + total_available_amount -= completed_work.payment_accrued + total_assigned_count += 1 + + return total_assigned_count diff --git a/commcare_connect/opportunity/utils/completed_work.py b/commcare_connect/opportunity/utils/completed_work.py index 202bbb8d..df2d9906 100644 --- a/commcare_connect/opportunity/utils/completed_work.py +++ b/commcare_connect/opportunity/utils/completed_work.py @@ -1,4 +1,11 @@ -from commcare_connect.opportunity.models import CompletedWorkStatus, VisitReviewStatus, VisitValidationStatus +from commcare_connect.opportunity.models import ( + CompletedWork, + CompletedWorkStatus, + OpportunityAccess, + Payment, + VisitReviewStatus, + VisitValidationStatus, +) def update_status(completed_works, opportunity_access, compute_payment=True): @@ -35,3 +42,38 @@ def update_status(completed_works, opportunity_access, compute_payment=True): if compute_payment: opportunity_access.payment_accrued = payment_accrued opportunity_access.save() + + +def update_work_payment_date(access: OpportunityAccess): + payments = Payment.objects.filter(opportunity_access=access).order_by("date_paid") + completed_works = CompletedWork.objects.filter(opportunity_access=access).order_by("status_modified_date") + + if not payments or not completed_works: + return + + works_to_update = [] + completed_works_iter = iter(completed_works) + current_work = next(completed_works_iter) + + remaining_amount = 0 + + for payment in payments: + remaining_amount += payment.amount + + while remaining_amount >= current_work.payment_accrued: + current_work.payment_date = payment.date_paid + works_to_update.append(current_work) + remaining_amount -= current_work.payment_accrued + + try: + current_work = next(completed_works_iter) + except StopIteration: + break + else: + continue + + # we've broken out of the inner while loop so all completed_works are processed. + break + + if works_to_update: + CompletedWork.objects.bulk_update(works_to_update, ["payment_date"]) diff --git a/commcare_connect/opportunity/visit_import.py b/commcare_connect/opportunity/visit_import.py index 51755848..ec2258df 100644 --- a/commcare_connect/opportunity/visit_import.py +++ b/commcare_connect/opportunity/visit_import.py @@ -19,7 +19,7 @@ VisitValidationStatus, ) from commcare_connect.opportunity.tasks import send_payment_notification -from commcare_connect.opportunity.utils.completed_work import update_status +from commcare_connect.opportunity.utils.completed_work import update_status, update_work_payment_date from commcare_connect.utils.file import get_file_extension from commcare_connect.utils.itertools import batched @@ -263,6 +263,7 @@ def _bulk_update_payments(opportunity: Opportunity, imported_data: Dataset) -> P payment = Payment.objects.create(opportunity_access=access, amount=amount) seen_users.add(username) payment_ids.append(payment.pk) + update_work_payment_date(access) missing_users = set(usernames) - seen_users send_payment_notification.delay(opportunity.id, payment_ids) return PaymentImportStatus(seen_users, missing_users)