From babac68faffdb3397b7a1d81c58fe89c1b80a345 Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Thu, 3 Oct 2024 20:07:55 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20display=20payment=20schedu?= =?UTF-8?q?le=20in=20contract=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 1 + src/backend/joanie/core/factories.py | 49 ++++++++++----- src/backend/joanie/core/models/products.py | 4 +- .../templates/issuers/contract_definition.css | 62 +++++++++++++++++++ .../issuers/contract_definition.html | 33 +++++++++- .../joanie/core/utils/contract_definition.py | 26 ++++++++ .../tests/core/models/order/test_schedule.py | 6 +- ...ct_definition_generate_document_context.py | 14 +++-- ...s_contract_definition_generate_document.py | 21 +++++-- 9 files changed, 185 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efe2bace3..b534c02df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/backend/joanie/core/factories.py b/src/backend/joanie/core/factories.py index 16e742bbb..00593b4b0 100644 --- a/src/backend/joanie/core/factories.py +++ b/src/backend/joanie/core/factories.py @@ -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 @@ -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) @@ -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 @@ -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, @@ -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() diff --git a/src/backend/joanie/core/models/products.py b/src/backend/joanie/core/models/products.py index e2b593df4..b69b96cd4 100644 --- a/src/backend/joanie/core/models/products.py +++ b/src/backend/joanie/core/models/products.py @@ -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 @@ -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 diff --git a/src/backend/joanie/core/templates/issuers/contract_definition.css b/src/backend/joanie/core/templates/issuers/contract_definition.css index 20d5d31cb..3dfa0cc92 100644 --- a/src/backend/joanie/core/templates/issuers/contract_definition.css +++ b/src/backend/joanie/core/templates/issuers/contract_definition.css @@ -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; diff --git a/src/backend/joanie/core/templates/issuers/contract_definition.html b/src/backend/joanie/core/templates/issuers/contract_definition.html index 11786a5aa..d364ccd2f 100644 --- a/src/backend/joanie/core/templates/issuers/contract_definition.html +++ b/src/backend/joanie/core/templates/issuers/contract_definition.html @@ -137,8 +137,39 @@

{{ contract.title }}

- {% if syllabus %} + {% if syllabus or student.payment_schedule %}

{% translate "Appendices" %}

+ {% endif %} + {% if student.payment_schedule %} +

{% translate "Payment schedule" %}

+ + + + + + + + + + + + + + + {% for payment in student.payment_schedule %} + + + + + {% endfor %} + +
{% translate "Due date" %}{% translate "Amount" %}
+ {% translate "Total :" %} + + {{ course.price|floatformat:2 }} {{ course.currency }} +
{{ payment.due_date|iso8601_to_date:"SHORT_DATE_FORMAT" }}{{ payment.amount|floatformat:2 }} {{ course.currency }}
+ {% endif %} + {% if syllabus %}

{% translate "Catalog syllabus" %}

{% include "contract_definition/fragment_appendice_syllabus.html" with syllabus=syllabus %} {% endif %} diff --git a/src/backend/joanie/core/utils/contract_definition.py b/src/backend/joanie/core/utils/contract_definition.py index c4a613735..1e7b2e91b 100644 --- a/src/backend/joanie/core/utils/contract_definition.py +++ b/src/backend/joanie/core/utils/contract_definition.py @@ -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 _ @@ -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 = { @@ -105,6 +107,7 @@ def generate_document_context(contract_definition=None, user=None, order=None): user_email = _("") user_phone_number = _("") user_address = USER_FALLBACK_ADDRESS + payment_schedule = None contract_body = _("") contract_title = _("") @@ -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) @@ -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, diff --git a/src/backend/joanie/tests/core/models/order/test_schedule.py b/src/backend/joanie/tests/core/models/order/test_schedule.py index 322612b15..80ec08bf7 100644 --- a/src/backend/joanie/tests/core/models/order/test_schedule.py +++ b/src/backend/joanie/tests/core/models/order/test_schedule.py @@ -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) @@ -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) @@ -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']" diff --git a/src/backend/joanie/tests/core/utils/test_contract_definition_generate_document_context.py b/src/backend/joanie/tests/core/utils/test_contract_definition_generate_document_context.py index 92eaa2196..65126c535 100644 --- a/src/backend/joanie/tests/core/utils/test_contract_definition_generate_document_context.py +++ b/src/backend/joanie/tests/core/utils/test_contract_definition_generate_document_context.py @@ -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, @@ -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": { @@ -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": { @@ -319,6 +324,7 @@ def test_utils_contract_definition_generate_document_context_default_placeholder }, "email": "", "phone_number": "", + "payment_schedule": None, }, "organization": { "address": { diff --git a/src/backend/joanie/tests/core/utils/test_issuers_contract_definition_generate_document.py b/src/backend/joanie/tests/core/utils/test_issuers_contract_definition_generate_document.py index 64643ffba..ffaabea30 100644 --- a/src/backend/joanie/tests/core/utils/test_issuers_contract_definition_generate_document.py +++ b/src/backend/joanie/tests/core/utils/test_issuers_contract_definition_generate_document.py @@ -113,10 +113,11 @@ def test_utils_issuers_contract_definition_generate_document(self): definition_checksum="1234", ) contract.refresh_from_db() + order.generate_schedule() file_bytes = issuers.generate_document( - name=contract.definition.name, - context=contract.context, + name=order.contract.definition.name, + context=order.contract.context, ) document_text = pdf_extract_text(BytesIO(file_bytes)).replace("\n", " ") @@ -157,8 +158,17 @@ def test_utils_issuers_contract_definition_generate_document(self): self.assertIn("Terms and conditions", document_text) self.assertIn("Terms and Conditions Content", document_text) - # - Appendices should be displayed - self.assertNotIn("Appendices", document_text) + # - Appendices title should be displayed + self.assertIn("Appendices", document_text) + # - Payment schedule should be displayed + self.assertIn("Payment schedule", document_text) + self.assertIn("Due date", document_text) + self.assertIn("Amount", document_text) + for installment in order.payment_schedule: + self.assertIn(installment["due_date"].strftime("%m/%d/%Y"), document_text) + self.assertIn(f"{installment['amount']:.2f}\xa0€", document_text) + self.assertIn("Total : 999.99\xa0€", document_text) + # - Syllabus should not be displayed self.assertNotIn("Syllabus", document_text) # - Signature slots should be displayed @@ -249,8 +259,9 @@ def test_utils_issuers_contract_definition_generate_document_with_placeholders( self.assertIn("Terms and conditions", document_text) self.assertIn("Terms and Conditions Content", document_text) - # - Appendices should be displayed + # - Appendices should not be displayed self.assertNotIn("Appendices", document_text) + self.assertNotIn("Payment schedule", document_text) self.assertNotIn("Syllabus", document_text) # - Signature slots should be displayed