Skip to content

Commit

Permalink
✨(backend) display payment schedule in contract template
Browse files Browse the repository at this point in the history
For legal purpose, we must display the order payment schedule in the training
contract that's why we bind this information into the contract template.
  • Loading branch information
jbpenrath committed Oct 7, 2024
1 parent 754f6f5 commit 2bbec06
Show file tree
Hide file tree
Showing 9 changed files with 185 additions and 31 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to
- Send an email to the user when an installment is successfully
paid
- Support of payment_schedule for certificate products
- Display payment schedule in contract template

### Changed

Expand Down
49 changes: 33 additions & 16 deletions src/backend/joanie/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -851,11 +851,18 @@ def credit_card(self):
# pylint: disable=unused-argument
def target_courses(self, create, extracted, **kwargs):
"""
If the order has a state other than draft, it should have been submitted so
If the order has a state other than draft, it should have been init so
target courses should have been copied from the product target courses.
"""
if extracted:
self.target_courses.set(extracted)
if not extracted or not create:
return

for position, course in enumerate(extracted):
OrderTargetCourseRelationFactory(
order=self,
course=course,
position=position,
)

@factory.post_generation
# pylint: disable=unused-argument, too-many-branches
Expand All @@ -874,17 +881,18 @@ def billing_address(self, create, extracted, **kwargs):
]:
self.state = enums.ORDER_STATE_DRAFT

CourseRunFactory(
course=self.course,
is_gradable=True,
state=CourseState.ONGOING_OPEN,
end=django_timezone.now() + timedelta(days=200),
)
ProductTargetCourseRelationFactory(
product=self.product,
course=self.course,
is_graded=True,
)
if not self.product.target_courses.exists():
CourseRunFactory(
course=self.course,
is_gradable=True,
state=CourseState.ONGOING_OPEN,
end=django_timezone.now() + timedelta(days=200),
)
ProductTargetCourseRelationFactory(
product=self.product,
course=self.course,
is_graded=True,
)

if extracted:
self.init_flow(billing_address=extracted)
Expand Down Expand Up @@ -1101,7 +1109,7 @@ def context(self):
"""
Lazily generate the contract context from the related order and contract definition.
"""
if self.student_signed_on:
if self.student_signed_on or self.submitted_for_signature_on:
student_address = self.order.owner.addresses.filter(is_main=True).first()
organization_address = self.order.organization.addresses.filter(
is_main=True
Expand Down Expand Up @@ -1152,6 +1160,15 @@ def context(self):
"address": AddressSerializer(student_address).data,
"email": self.order.owner.email,
"phone_number": self.order.owner.phone_number,
"payment_schedule": [
{
"due_date": installment["due_date"].isoformat(),
"amount": str(installment["amount"]),
}
for installment in self.order.payment_schedule
]
if self.order.payment_schedule
else None,
},
"organization": {
"logo_id": organization_logo_id,
Expand Down Expand Up @@ -1179,7 +1196,7 @@ def definition_checksum(self):
"""
Lazily generate the definition_checksum from context.
"""
if self.student_signed_on:
if self.student_signed_on or self.submitted_for_signature_on:
return hashlib.sha256(
json.dumps(self.context, sort_keys=True).encode("utf-8")
).hexdigest()
Expand Down
4 changes: 2 additions & 2 deletions src/backend/joanie/core/models/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -1068,7 +1068,7 @@ def get_equivalent_course_run_dates(self):
for key, value in aggregate.items()
}

def _get_schedule_dates(self):
def get_schedule_dates(self):
"""
Return the schedule dates for the order.
The schedules date are based on contract sign date or the time the schedule is generated
Expand Down Expand Up @@ -1099,7 +1099,7 @@ def generate_schedule(self):
Generate payment schedule for the order.
"""
beginning_contract_date, course_start_date, course_end_date = (
self._get_schedule_dates()
self.get_schedule_dates()
)
installments = generate_payment_schedule(
self.total, beginning_contract_date, course_start_date, course_end_date
Expand Down
62 changes: 62 additions & 0 deletions src/backend/joanie/core/templates/issuers/contract_definition.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,68 @@
page-break-after: always;
}

/* == Context - Payment schedule appendix */
.installments-table {
border-collapse: separate;
border-spacing: 0;
}

.installments-table th {
padding: 1mm 2mm;
}
.installments-table tfoot td:last-child,
.installments-table tbody td {
padding: 3mm 2mm;
}

.installments-table th:first-child {
min-width: 40mm;
}
.installments-table th:last-child {
min-width: 25mm;
}

.installments-table tfoot td:first-child {
text-align: right;
}

.installments-table tbody tr td:first-child{
border-left: thin solid #BBB;
}

.installments-table tbody tr td:last-child{
border-right: thin solid #BBB;
}

.installments-table tbody tr:first-child td:first-child{
border-left: thin solid #BBB;
border-top: thin solid #BBB;
border-radius: 4px 0 0 0;
}
.installments-table tbody tr:first-child td:last-child{
border-right: thin solid #BBB;
border-top: thin solid #BBB;
border-radius: 0 4px 0 0;
}
.installments-table tbody tr:last-child td:first-child{
border-left: thin solid #BBB;
border-bottom: thin solid #BBB;
border-radius: 0 0 0 4px;
}
.installments-table tbody tr:last-child td:last-child{
border-right: thin solid #BBB;
border-bottom: thin solid #BBB;
border-radius: 0 0 4px 0;
}

.installments-table tr:nth-child(even) {
background: #ebebeb;
}

.installments-table tfoot {
font-weight: bold;
}

/* == Content - Syllabus appendix */
.syllabus--header {
background: #e67a00;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,39 @@ <h1>{{ contract.title }}</h1>
</div>
</div>
</section>
{% if syllabus %}
{% if syllabus or student.payment_schedule %}
<h2>{% translate "Appendices" %}</h2>
{% endif %}
{% if student.payment_schedule %}
<h3>{% translate "Payment schedule" %}</h3>
<table class="installments-table">
<thead>
<tr>
<th>{% translate "Due date" %}</th>
<th>{% translate "Amount" %}</th>
</tr>
</thead>
<tfoot>
<tr>
<td>
{% translate "Total :" %}
</td>
<td>
{{ course.price|floatformat:2 }}&nbsp;{{ course.currency }}
</td>
</tr>
</tfoot>
<tbody>
{% for payment in student.payment_schedule %}
<tr>
<td>{{ payment.due_date|iso8601_to_date:"SHORT_DATE_FORMAT" }}</td>
<td>{{ payment.amount|floatformat:2 }}&nbsp;{{ course.currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if syllabus %}
<h3>{% translate "Catalog syllabus" %}</h3>
{% include "contract_definition/fragment_appendice_syllabus.html" with syllabus=syllabus %}
{% endif %}
Expand Down
26 changes: 26 additions & 0 deletions src/backend/joanie/core/utils/contract_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from datetime import date, timedelta

from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.duration import duration_iso_string
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
Expand All @@ -12,6 +13,7 @@

from joanie.core.models import DocumentImage
from joanie.core.utils import file_checksum, image_to_base64
from joanie.core.utils.payment_schedule import generate as generate_payment_schedule

# Organization section for generating contract definition
ORGANIZATION_FALLBACK_ADDRESS = {
Expand Down Expand Up @@ -105,6 +107,7 @@ def generate_document_context(contract_definition=None, user=None, order=None):
user_email = _("<STUDENT_EMAIL>")
user_phone_number = _("<STUDENT_PHONE_NUMBER>")
user_address = USER_FALLBACK_ADDRESS
payment_schedule = None

contract_body = _("<CONTRACT_BODY>")
contract_title = _("<CONTRACT_TITLE>")
Expand Down Expand Up @@ -166,6 +169,28 @@ def generate_document_context(contract_definition=None, user=None, order=None):
course_price = str(order.total)
user_address = order.main_invoice.recipient_address

# Payment Schedule
try:
beginning_contract_date, course_start_date, course_end_date = (
order.get_schedule_dates()
)
except ValidationError:
pass
else:
installments = generate_payment_schedule(
order.total,
beginning_contract_date,
course_start_date,
course_end_date,
)
payment_schedule = [
{
"due_date": installment["due_date"].isoformat(),
"amount": str(installment["amount"]),
}
for installment in installments
]

# Transform duration value to ISO 8601 format
if isinstance(course_effort, timedelta):
course_effort = duration_iso_string(course_effort)
Expand Down Expand Up @@ -202,6 +227,7 @@ def generate_document_context(contract_definition=None, user=None, order=None):
"name": user_name,
"email": user_email,
"phone_number": user_phone_number,
"payment_schedule": payment_schedule,
},
"organization": {
"address": organization_address,
Expand Down
6 changes: 3 additions & 3 deletions src/backend/joanie/tests/core/models/order/test_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def test_models_order_schedule_get_schedule_dates_with_contract(self):
)

signed_contract_date, course_start_date, course_end_date = (
contract.order._get_schedule_dates()
contract.order.get_schedule_dates()
)

self.assertEqual(signed_contract_date, student_signed_on_date)
Expand All @@ -92,7 +92,7 @@ def test_models_order_schedule_get_schedule_dates_without_contract(self):
mocked_now = datetime(2024, 1, 1, 14, tzinfo=ZoneInfo("UTC"))
with mock.patch("django.utils.timezone.now", return_value=mocked_now):
signed_contract_date, course_start_date, course_end_date = (
order._get_schedule_dates()
order.get_schedule_dates()
)

self.assertEqual(signed_contract_date, mocked_now)
Expand All @@ -112,7 +112,7 @@ def test_models_order_schedule_get_schedule_dates_no_course_run(self):
self.assertRaises(ValidationError) as context,
self.assertLogs("joanie") as logger,
):
contract.order._get_schedule_dates()
contract.order.get_schedule_dates()

self.assertEqual(
str(context.exception), "['Cannot retrieve start or end date for order']"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,13 @@ def test_utils_contract_definition_generate_document_context_with_order(self):
effort=timedelta(hours=10, minutes=30, seconds=12),
),
)
order = factories.OrderFactory(
order = factories.OrderGeneratorFactory(
owner=user,
product=relation.product,
course=relation.course,
state=enums.ORDER_STATE_COMPLETED,
main_invoice=InvoiceFactory(recipient_address=user_address),
)
factories.OrderTargetCourseRelationFactory(
course=relation.course, order=order, position=1
)

context = contract_definition.generate_document_context(
contract_definition=order.product.contract_definition,
Expand Down Expand Up @@ -157,6 +154,13 @@ def test_utils_contract_definition_generate_document_context_with_order(self):
},
"email": user.email,
"phone_number": str(user.phone_number),
"payment_schedule": [
{
"due_date": installment["due_date"].isoformat(),
"amount": str(installment["amount"]),
}
for installment in order.payment_schedule
],
},
"organization": {
"address": {
Expand Down Expand Up @@ -243,6 +247,7 @@ def test_utils_contract_definition_generate_document_context_without_order(self)
},
"email": str(user.email),
"phone_number": str(user.phone_number),
"payment_schedule": None,
},
"organization": {
"address": {
Expand Down Expand Up @@ -319,6 +324,7 @@ def test_utils_contract_definition_generate_document_context_default_placeholder
},
"email": "<STUDENT_EMAIL>",
"phone_number": "<STUDENT_PHONE_NUMBER>",
"payment_schedule": None,
},
"organization": {
"address": {
Expand Down
Loading

0 comments on commit 2bbec06

Please sign in to comment.