Skip to content

Commit

Permalink
Merge branch 'pkv/invoice-form-and-payments' into pkv/staging-deploy
Browse files Browse the repository at this point in the history
  • Loading branch information
pxwxnvermx committed Sep 5, 2024
2 parents 9279c2a + 43cec93 commit b719b75
Show file tree
Hide file tree
Showing 8 changed files with 309 additions and 14 deletions.
33 changes: 33 additions & 0 deletions commcare_connect/opportunity/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
Opportunity,
OpportunityAccess,
OpportunityVerificationFlags,
PaymentInvoice,
PaymentUnit,
VisitValidationStatus,
)
Expand Down Expand Up @@ -850,3 +851,35 @@ def __init__(self, *args, **kwargs):
self.fields["deliver_unit"] = forms.ModelMultipleChoiceField(
queryset=DeliverUnit.objects.filter(app=self.opportunity.deliver_app), widget=forms.CheckboxSelectMultiple
)


class PaymentInvoiceForm(forms.ModelForm):
class Meta:
model = PaymentInvoice
fields = ("amount", "date", "invoice_number")
widgets = {"date": forms.DateInput(attrs={"type": "date", "class": "form-control"})}

def __init__(self, *args, **kwargs):
self.opportunity = kwargs.pop("opportunity")
super().__init__(*args, **kwargs)

self.helper = FormHelper(self)
self.helper.layout = Layout(
Row(Field("amount")),
Row(Field("date")),
Row(Field("invoice_number")),
)
self.helper.form_tag = False

def clean_invoice_number(self):
invoice_number = self.cleaned_data["invoice_number"]
if PaymentInvoice.objects.filter(opportunity=self.opportunity, invoice_number=invoice_number).exists():
raise ValidationError(f"Invoice with {invoice_number} number already exists", code="invoice_number_reused")
return invoice_number

def save(self, commit=True):
instance = super().save(commit=False)
instance.opportunity = self.opportunity
if commit:
instance.save()
return instance
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 4.2.5 on 2024-09-05 08:08

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
dependencies = [
("opportunity", "0056_payment_organization"),
]

operations = [
migrations.CreateModel(
name="PaymentInvoice",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("amount", models.PositiveIntegerField()),
("date", models.DateField()),
("invoice_number", models.CharField(max_length=50)),
(
"opportunity",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="opportunity.opportunity"),
),
],
options={
"unique_together": {("opportunity", "invoice_number")},
},
),
migrations.AddField(
model_name="payment",
name="invoice",
field=models.OneToOneField(
blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to="opportunity.paymentinvoice"
),
),
]
11 changes: 11 additions & 0 deletions commcare_connect/opportunity/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,16 @@ class VisitValidationStatus(models.TextChoices):
trial = "trial", gettext("Trial")


class PaymentInvoice(models.Model):
opportunity = models.ForeignKey(Opportunity, on_delete=models.CASCADE)
amount = models.PositiveIntegerField()
date = models.DateField()
invoice_number = models.CharField(max_length=50)

class Meta:
unique_together = ("opportunity", "invoice_number")


class Payment(models.Model):
amount = models.PositiveIntegerField()
date_paid = models.DateTimeField(auto_now_add=True)
Expand All @@ -403,6 +413,7 @@ class Payment(models.Model):
confirmation_date = models.DateTimeField(null=True)
# This is used to indicate Payments made to Network Manager organizations
organization = models.ForeignKey(Organization, on_delete=models.DO_NOTHING, null=True, blank=True)
invoice = models.OneToOneField(PaymentInvoice, on_delete=models.DO_NOTHING, null=True, blank=True)


class CompletedWorkStatus(models.TextChoices):
Expand Down
30 changes: 30 additions & 0 deletions commcare_connect/opportunity/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
CompletedWork,
OpportunityAccess,
Payment,
PaymentInvoice,
PaymentUnit,
UserInvite,
UserInviteStatus,
Expand Down Expand Up @@ -420,6 +421,35 @@ class Meta:
orderable = False


class PaymentInvoiceTable(tables.Table):
pk = columns.CheckBoxColumn(
accessor="pk",
verbose_name="",
attrs={
"input": {"x-model": "selected"},
"th__input": {"@click": "toggleSelectAll()", "x-bind:checked": "selectAll"},
},
)
payment_status = columns.Column(verbose_name="Payment Status", accessor="payment", empty_values=())
payment_date = columns.Column(verbose_name="Payment Date", accessor="payment", empty_values=(None))

class Meta:
model = PaymentInvoice
orderable = False
fields = ("pk", "amount", "date", "invoice_number")
empty_text = "No Payment Invoices"

def render_payment_status(self, value):
if value is not None:
return "Paid"
return "Pending"

def render_payment_date(self, value):
if value is not None:
return value.date_paid
return


def popup_html(value, popup_title, popup_direction="top", popup_class="", popup_attributes=""):
return format_html(
"<span class='{}' data-bs-toggle='tooltip' data-bs-placement='{}' data-bs-title='{}' {}>{}</span>",
Expand Down
5 changes: 5 additions & 0 deletions commcare_connect/opportunity/urls.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.urls import path

from commcare_connect.opportunity import views
from commcare_connect.opportunity.views import (
OpportunityCompletedWorkTable,
OpportunityCreate,
Expand Down Expand Up @@ -107,4 +108,8 @@
path("<int:pk>/user_invite/", view=opportunity_user_invite, name="user_invite"),
path("<int:opp_id>/user_visit_review", user_visit_review, name="user_visit_review"),
path("<int:pk>/payment_report", payment_report, name="payment_report"),
path("<int:pk>/invoice/", views.invoice_list, name="invoice_list"),
path("<int:pk>/invoice_table/", views.PaymentInvoiceTableView.as_view(), name="invoice_table"),
path("<int:pk>/invoice/create/", views.invoice_create, name="invoice_create"),
path("<int:pk>/invoice/approve/", views.invoice_approve, name="invoice_approve"),
]
70 changes: 70 additions & 0 deletions commcare_connect/opportunity/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from functools import reduce

from celery.result import AsyncResult
from crispy_forms.utils import render_crispy_form
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
Expand Down Expand Up @@ -38,6 +39,7 @@
OpportunityUserInviteForm,
OpportunityVerificationFlagsConfigForm,
PaymentExportForm,
PaymentInvoiceForm,
PaymentUnitForm,
SendMessageMobileUsersForm,
VisitExportForm,
Expand All @@ -60,6 +62,7 @@
OpportunityClaimLimit,
OpportunityVerificationFlags,
Payment,
PaymentInvoice,
PaymentUnit,
UserVisit,
VisitValidationStatus,
Expand All @@ -69,6 +72,7 @@
DeliverStatusTable,
LearnStatusTable,
OpportunityPaymentTable,
PaymentInvoiceTable,
PaymentReportTable,
PaymentUnitTable,
SuspendedUsersTable,
Expand Down Expand Up @@ -1171,3 +1175,69 @@ def payment_report(request, org_slug, pk):
total_nm_payment_accrued=total_nm_payment_accrued,
),
)


class PaymentInvoiceTableView(OrganizationUserMixin, SingleTableView):
model = PaymentInvoice
paginate_by = 25
table_class = PaymentInvoiceTable
template_name = "tables/single_table.html"

def get_table_kwargs(self):
kwargs = super().get_table_kwargs()
if not self.request.org_membership.is_program_manager:
kwargs["exclude"] = ("pk",)
return kwargs

def get_queryset(self):
opportunity_id = self.kwargs["pk"]
opportunity = get_opportunity_or_404(org_slug=self.request.org.slug, pk=opportunity_id)
filter_kwargs = dict(opportunity=opportunity)
table_filter = self.request.GET.get("filter")
if table_filter is not None and table_filter in ["paid", "pending"]:
filter_kwargs["payment__isnull"] = table_filter == "pending"
return PaymentInvoice.objects.filter(**filter_kwargs).order_by("date")


@org_member_required
def invoice_list(request, org_slug, pk):
opportunity = get_opportunity_or_404(pk, org_slug)
if not opportunity.managed:
return redirect("opportunity:detail", org_slug, pk)
form = PaymentInvoiceForm(opportunity=opportunity)
return render(
request,
"opportunity/invoice_list.html",
context=dict(opportunity=opportunity, form=form),
)


@org_member_required
def invoice_create(request, org_slug, pk):
opportunity = get_opportunity_or_404(pk, org_slug)
if not opportunity.managed or request.org_membership.is_program_manager:
return redirect("opportunity:detail", org_slug, pk)
form = PaymentInvoiceForm(data=request.POST or None, opportunity=opportunity)
if request.POST and form.is_valid():
form.save()
form = PaymentInvoiceForm(opportunity=opportunity)
return HttpResponse(render_crispy_form(form), headers={"HX-Trigger": "newInvoice"})
return HttpResponse(render_crispy_form(form))


@org_member_required
@require_POST
def invoice_approve(request, org_slug, pk):
opportunity = get_opportunity_or_404(pk, org_slug)
if not opportunity.managed or not request.org_membership.is_program_manager:
return redirect("opportunity:detail", org_slug, pk)
invoice_ids = request.POST.getlist("pk")
invoices = PaymentInvoice.objects.filter(opportunity=opportunity, pk__in=invoice_ids, payment__isnull=True)
for invoice in invoices:
payment = Payment(
amount=invoice.amount,
organization=opportunity.organization,
invoice=invoice,
)
payment.save()
return HttpResponse(headers={"HX-Trigger": "newInvoice"})
103 changes: 103 additions & 0 deletions commcare_connect/templates/opportunity/invoice_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
{% extends "opportunity/base.html" %}
{% load django_tables2 %}
{% load static %}
{% load crispy_forms_tags %}

{% block title %}{{ request.org }} - Invoices{% endblock title %}

{% block breadcrumbs_inner %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'opportunity:detail' org_slug=request.org.slug pk=opportunity.pk %}">
{{ opportunity.name }}
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Invoices</li>
{% endblock %}

{% block content %}
<h2>Invoices</h2>
<hr />
{% if not request.org_membership.is_program_manager %}
<button type="button" class="btn btn-primary mb-2" data-bs-toggle="modal" data-bs-target="#invoiceModal">
Add New Invoice
</button>
{% endif %}

<form hx-get="{% url "opportunity:invoice_table" org_slug=request.org.slug pk=opportunity.pk %}"
hx-trigger="load, change, newInvoice from:body"
hx-target="#invoiceTable">
<select class="form-select mb-2" id="filterSelect" name="filter">
<option selected value="">Filter Invoices</option>
<option value="paid">Paid</option>
<option value="pending">Pending</option>
</select>
</form>

{% if request.org_membership.is_program_manager %}
<form x-data="{
selectAll: false,
selected: [],
toggleSelectAll() {
this.selectAll = !this.selectAll;
if(this.selectAll)
this.selected = JSON.parse(document.getElementById('invoice_ids').textContent);
else
this.selected = [];
}
}"
hx-post="{% url "opportunity:invoice_approve" org_slug=request.org.slug pk=opportunity.pk %}"
hx-swap="none"
>
{% csrf_token %}
{% endif %}

<div id="invoiceTable">
{% include "tables/table_placeholder.html" with num_cols=4 %}
</div>

{% if request.org_membership.is_program_manager %}
<button type="submit" class="btn btn-primary mt-2">Pay</button>
</form>
{% endif %}
{% endblock content %}

{% if not request.org_membership.is_program_manager %}
{% block modal %}
<div class="modal fade" id="invoiceModal" tabindex="-1" role="dialog" aria-labelledby="invoiceModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="invoiceModalLabel">Create Invoice</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form hx-post="{% url "opportunity:invoice_create" org_slug=request.org.slug pk=opportunity.pk %}"
hx-trigger="submit"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-target="#formFields">
<div class="modal-body">
<div id="formFields">{% crispy form %}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-primary">Save changes</button>
</div>
</form>
</div>
</div>
</div>
{% endblock modal %}
{% endif %}

{% block inline_javascript %}
<script>
window.addEventListener('DOMContentLoaded', () => {
const filter = document.querySelector("#filterSelect");
filter.addEventListener("change", (event) => {
const url = new URL(window.location);
url.searchParams.set("filter", event.target.value)
history.pushState(null, '', url);
});
});
</script>
{% endblock inline_javascript %}
Loading

0 comments on commit b719b75

Please sign in to comment.