diff --git a/pay-api/migrations/versions/fd5c02bb9e01_.py b/pay-api/migrations/versions/fd5c02bb9e01_.py new file mode 100644 index 000000000..98d886d4a --- /dev/null +++ b/pay-api/migrations/versions/fd5c02bb9e01_.py @@ -0,0 +1,21 @@ +"""Migration to set cas_version_suffix to 1 for existing records + +Revision ID: fd5c02bb9e01 +Revises: f497d603602e +Create Date: 2022-12-15 13:12:09.563178 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fd5c02bb9e01' +down_revision = 'f497d603602e' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute('update routing_slips set cas_version_suffix = 1 where cas_version_suffix is null') + pass diff --git a/pay-api/src/pay_api/models/payment.py b/pay-api/src/pay_api/models/payment.py index 6c21c2a05..d98f733e0 100644 --- a/pay-api/src/pay_api/models/payment.py +++ b/pay-api/src/pay_api/models/payment.py @@ -102,6 +102,14 @@ def find_payment_for_invoice(cls, invoice_id: int): return query.one_or_none() + @classmethod + def find_payments_for_routing_slip(cls, routing_slip: str): + """Find payment records created for a routing slip.""" + return db.session.query(Payment) \ + .filter(Payment.receipt_number == routing_slip) \ + .filter(Payment.is_routing_slip.is_(True)) \ + .all() + @classmethod def search_account_payments(cls, auth_account_id: str, payment_status: str, page: int, limit: int): """Search payment records created for the account.""" diff --git a/pay-api/src/pay_api/models/routing_slip.py b/pay-api/src/pay_api/models/routing_slip.py index 890350895..4c2dabebd 100644 --- a/pay-api/src/pay_api/models/routing_slip.py +++ b/pay-api/src/pay_api/models/routing_slip.py @@ -83,6 +83,15 @@ class RoutingSlip(Audit): # pylint: disable=too-many-instance-attributes parent = relationship('RoutingSlip', remote_side=[number], lazy='select') + def generate_cas_receipt_number(self) -> str: + """Return a unique identifier - receipt number for CAS.""" + receipt_number: str = self.number + if self.parent_number: + receipt_number += 'L' + if self.cas_version_suffix > 1: + receipt_number += f'R{self.cas_version_suffix}' + return receipt_number + @classmethod def find_by_number(cls, number: str) -> RoutingSlip: """Return a routing slip by number.""" diff --git a/pay-api/src/pay_api/services/fas/routing_slip.py b/pay-api/src/pay_api/services/fas/routing_slip.py index 9da51e338..36eac2b6f 100644 --- a/pay-api/src/pay_api/services/fas/routing_slip.py +++ b/pay-api/src/pay_api/services/fas/routing_slip.py @@ -26,6 +26,7 @@ from pay_api.models import PaymentAccount as PaymentAccountModel from pay_api.models import RoutingSlip as RoutingSlipModel from pay_api.models import RoutingSlipSchema +from pay_api.models import Comment as CommentModel from pay_api.services.fas.routing_slip_status_transition_service import RoutingSlipStatusTransitionService from pay_api.services.oauth_service import OAuthService from pay_api.utils.enums import ( @@ -393,7 +394,6 @@ def update(cls, rs_number: str, action: str, request_json: Dict[str, any], **kwa raise BusinessException(Error.FAS_INVALID_ROUTING_SLIP_NUMBER) if patch_action == PatchActions.UPDATE_STATUS: - # Update the remaining amount as negative total of sum of all totals for that routing slip. status = request_json.get('status') RoutingSlipStatusTransitionService.validate_possible_transitions(routing_slip, status) status = RoutingSlipStatusTransitionService.get_actual_status(status) @@ -401,6 +401,7 @@ def update(cls, rs_number: str, action: str, request_json: Dict[str, any], **kwa RoutingSlip._check_roles_for_status_update(status, user) # Our routing_slips job will create an invoice (under transactions in the UI). if status == RoutingSlipStatus.NSF.value: + # Update the remaining amount as negative total of sum of all totals for that routing slip. total_paid_to_reverse: float = 0 for rs in (routing_slip, *RoutingSlipModel.find_children(routing_slip.number)): total_paid_to_reverse += rs.total @@ -409,12 +410,45 @@ def update(cls, rs_number: str, action: str, request_json: Dict[str, any], **kwa if routing_slip.invoices: raise BusinessException(Error.RS_HAS_TRANSACTIONS) routing_slip.remaining_amount = 0 + # This is outside the normal flow of payments, thus why we've done it here in FAS. + elif status == RoutingSlipStatus.CORRECTION.value: + if not request_json.get('payments'): + raise BusinessException(Error.INVALID_REQUEST) + correction_total, comment = cls._calculate_correction_and_comment(rs_number, request_json) + routing_slip.total += correction_total + routing_slip.remaining_amount += correction_total + CommentModel(comment=comment, routing_slip_number=rs_number).flush() routing_slip.status = status routing_slip.save() return cls.find_by_number(rs_number) + @classmethod + def _calculate_correction_and_comment(cls, rs_number: str, request_json: Dict[str, any]): + correction_total = Decimal('0') + comment: str = '' + payments = PaymentModel.find_payments_for_routing_slip(rs_number) + for payment_request in request_json.get('payments'): + if (payment := next(x for x in payments if x.id == payment_request.get('id'))): + paid_amount = payment_request.get('paidAmount', 0) + correction_total += paid_amount - payment.paid_amount + if payment.payment_method_code == PaymentMethod.CASH.value: + comment += f'Cash Payment corrected amount' \ + f' ${payment.paid_amount} to ${paid_amount}\n' + else: + comment += f'Cheque Payment {payment.cheque_receipt_number}' + if cheque_receipt_number := payment_request.get('chequeReceiptNumber'): + payment.cheque_receipt_number = cheque_receipt_number + comment += f' cheque receipt number corrected to {cheque_receipt_number}' + if paid_amount != payment.paid_amount: + comment += f' corrected amount ${payment.paid_amount} to ${paid_amount}' + comment += '\n' + payment.paid_amount = paid_amount + payment.paid_usd_amount = payment_request.get('paidUsdAmount', 0) + payment.flush() + return correction_total, comment + @staticmethod def _check_roles_for_status_update(status: str, user: UserContext): """Check roles for the status.""" diff --git a/pay-api/tests/unit/api/fas/test_routing_slip.py b/pay-api/tests/unit/api/fas/test_routing_slip.py index 274063152..3b82ce065 100755 --- a/pay-api/tests/unit/api/fas/test_routing_slip.py +++ b/pay-api/tests/unit/api/fas/test_routing_slip.py @@ -833,7 +833,7 @@ def test_routing_slip_status_to_nsf_attempt(client, jwt, app): def test_routing_slip_void(client, jwt, app): """For testing void routing slips.""" - # Success, has transactions, has no permissions. + # Create routing slip. token = jwt.create_jwt(get_claims(roles=[Role.FAS_CREATE.value, Role.FAS_LINK.value, Role.FAS_SEARCH.value, Role.FAS_EDIT.value]), token_header) @@ -870,3 +870,64 @@ def test_routing_slip_void(client, jwt, app): data=json.dumps({'status': RoutingSlipStatus.VOID.value}), headers=headers) assert rv.status_code == 200 assert rv.json.get('remainingAmount') == 0 + + +def test_routing_slip_correction(client, jwt, app): + """For testing correction of routing slips.""" + # Create routing slip. + token = jwt.create_jwt(get_claims(roles=[Role.FAS_CREATE.value, Role.FAS_LINK.value, + Role.FAS_SEARCH.value, Role.FAS_EDIT.value]), + token_header) + headers = {'Authorization': f'Bearer {token}', 'content-type': 'application/json'} + rs = get_routing_slip_request('438607657') + rv = client.post('/api/v1/fas/routing-slips', data=json.dumps(rs), headers=headers) + payment_id = rv.json.get('payments')[0].get('id') + assert payment_id + + # Create invoice. + invoice = factory_invoice(PaymentAccount(id=rv.json.get('paymentAccount').get('id')), folio_number='test_folio', + routing_slip=rv.json.get('number'), + payment_method_code=PaymentMethod.INTERNAL.value) + invoice.save() + + # Failure case, no permissions + rv = client.patch(f"/api/v1/fas/routing-slips/{rs.get('number')}?action={PatchActions.UPDATE_STATUS.value}", + data=json.dumps({'status': RoutingSlipStatus.CORRECTION.value}), headers=headers) + assert rv.status_code == 403 + + token = jwt.create_jwt(get_claims(roles=[Role.FAS_VIEW.value, Role.FAS_EDIT.value, Role.FAS_CORRECTION.value]), + token_header) + headers = {'Authorization': f'Bearer {token}', 'content-type': 'application/json'} + + # Failure case, no payments. + rv = client.patch(f"/api/v1/fas/routing-slips/{rs.get('number')}?action={PatchActions.UPDATE_STATUS.value}", + data=json.dumps({'status': RoutingSlipStatus.CORRECTION.value}), headers=headers) + assert rv.status_code == 400 + + # Success case + payload = { + 'status': RoutingSlipStatus.CORRECTION.value, + 'payments': [ + { + 'id': payment_id, + 'paidAmount': 50, + 'paidUsdAmount': 50, + 'chequeReceiptNumber': '911' + } + ] + } + + rv = client.patch(f"/api/v1/fas/routing-slips/{rs.get('number')}?action={PatchActions.UPDATE_STATUS.value}", + data=json.dumps(payload), headers=headers) + assert rv.status_code == 200 + assert rv.json.get('total') == 50 + assert rv.json.get('remainingAmount') == 50 + assert rv.json.get('status') == RoutingSlipStatus.CORRECTION.value + assert rv.json.get('payments') + assert rv.json.get('payments')[0].get('paidAmount') == 50 + assert rv.json.get('payments')[0].get('paidUsdAmount') == 50 + assert rv.json.get('payments')[0].get('chequeReceiptNumber') == '911' + + rv = client.get(f"/api/v1/fas/routing-slips/{rs.get('number')}/comments", headers=headers) + assert rv.status_code == 200 + assert rv.json.get('comments')