Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Show payment metrics #405

Merged
merged 6 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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),
]
1 change: 1 addition & 0 deletions commcare_connect/opportunity/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions commcare_connect/opportunity/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
155 changes: 155 additions & 0 deletions commcare_connect/opportunity/tests/test_visit_import.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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
44 changes: 43 additions & 1 deletion commcare_connect/opportunity/utils/completed_work.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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"])
3 changes: 2 additions & 1 deletion commcare_connect/opportunity/visit_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Loading