diff --git a/pay-api/src/pay_api/models/refunds_partial.py b/pay-api/src/pay_api/models/refunds_partial.py index eb584cae9..11b0af1ff 100644 --- a/pay-api/src/pay_api/models/refunds_partial.py +++ b/pay-api/src/pay_api/models/refunds_partial.py @@ -16,6 +16,7 @@ from decimal import Decimal from attrs import define from sqlalchemy import ForeignKey + from .audit import Audit from .base_model import VersionedModel from .db import db diff --git a/pay-api/src/pay_api/schemas/schemas/refund.json b/pay-api/src/pay_api/schemas/schemas/refund.json index eeb600a6a..cae5e0ba3 100644 --- a/pay-api/src/pay_api/schemas/schemas/refund.json +++ b/pay-api/src/pay_api/schemas/schemas/refund.json @@ -13,6 +13,24 @@ "examples": [ "Duplicate Invoice" ] + }, + "refundRevenue": { + "type": "array", + "items": { + "type": "object", + "properties": { + "paymentLineItemId": { + "type": "integer" + }, + "refundAmount": { + "type": "number" + }, + "refundType": { + "type": "string" + } + }, + "required": ["paymentLineItemId", "refundAmount", "refundType"] + } } } -} \ No newline at end of file +} diff --git a/pay-api/src/pay_api/services/refund.py b/pay-api/src/pay_api/services/refund.py index dee24c267..205110cc6 100644 --- a/pay-api/src/pay_api/services/refund.py +++ b/pay-api/src/pay_api/services/refund.py @@ -16,18 +16,23 @@ from __future__ import annotations from datetime import datetime -from typing import Dict, Optional +from typing import Dict, List, Optional from flask import current_app from pay_api.exceptions import BusinessException from pay_api.factory.payment_system_factory import PaymentSystemFactory from pay_api.models import Invoice as InvoiceModel +from pay_api.models import PaymentLineItem as PaymentLineItemModel from pay_api.models import Refund as RefundModel +from pay_api.models import RefundsPartial as RefundPartialModel from pay_api.models import RoutingSlip as RoutingSlipModel +from pay_api.models import db +from pay_api.models import RefundPartialLine from pay_api.services.base_payment_system import PaymentSystemService from pay_api.services.payment_account import PaymentAccount from pay_api.utils.constants import REFUND_SUCCESS_MESSAGES +from pay_api.utils.converter import Converter from pay_api.utils.enums import InvoiceStatus, Role, RoutingSlipStatus from pay_api.utils.errors import Error from pay_api.utils.user_context import UserContext, user_context @@ -268,10 +273,12 @@ def create_refund(cls, invoice_id: int, request: Dict[str, str], **kwargs) -> Di payment_method=invoice.payment_method_code ) payment_account = PaymentAccount.find_by_id(invoice.payment_account_id) + refund_partial_lines = cls._get_partial_refund_lines(request.get('refundRevenue', None)) invoice_status = pay_system_service.process_cfs_refund(invoice, payment_account=payment_account, - refund_partial=None) # TODO 19760 + refund_partial=refund_partial_lines) refund.flush() + cls._save_partial_refund_lines(refund_partial_lines) message = REFUND_SUCCESS_MESSAGES.get(f'{invoice.payment_method_code}.{invoice.invoice_status_code}') # set invoice status invoice.invoice_status_code = invoice_status or InvoiceStatus.REFUND_REQUESTED.value @@ -279,3 +286,36 @@ def create_refund(cls, invoice_id: int, request: Dict[str, str], **kwargs) -> Di invoice.save() current_app.logger.debug(f'Completed refund : {invoice_id}') return {'message': message} + + @staticmethod + def _save_partial_refund_lines(partial_refund_lines: List[RefundPartialLine]): + """Persist a list of partial refund lines.""" + for line in partial_refund_lines: + refund_line = RefundPartialModel( + payment_line_item_id=line.payment_line_item_id, + refund_amount=line.refund_amount, + refund_type=line.refund_type + ) + db.session.add(refund_line) + + @staticmethod + def _get_partial_refund_lines(refund_revenue: List[Dict]) -> List[RefundPartialLine]: + """Convert Refund revenue data to a list of Partial Refund lines.""" + if not refund_revenue: + return [] + + return Converter(camel_to_snake_case=True, + enum_to_value=True).structure(refund_revenue, List[RefundPartialLine]) + + @staticmethod + def get_refund_partials_by_invoice_id(invoice_id: int): + """Return refund partials by invoice id.""" + return db.session.query(RefundPartialModel) \ + .join(PaymentLineItemModel, PaymentLineItemModel.id == RefundPartialModel.payment_line_item_id) \ + .filter(PaymentLineItemModel.invoice_id == invoice_id).all() + + @staticmethod + def get_refund_partials_by_payment_line_item_id(payment_line_item_id: int): + """Return refund partials by payment line item id.""" + return db.session.query(RefundPartialModel) \ + .filter(PaymentLineItemModel.id == payment_line_item_id).all() diff --git a/pay-api/src/pay_api/utils/converter.py b/pay-api/src/pay_api/utils/converter.py index 71d5e00ae..9697f13c3 100644 --- a/pay-api/src/pay_api/utils/converter.py +++ b/pay-api/src/pay_api/utils/converter.py @@ -1,14 +1,18 @@ """Converter module to support decimal and datetime serialization.""" -from decimal import Decimal +import re from datetime import datetime +from decimal import Decimal +from enum import Enum from typing import Any, Dict, Type import cattrs +from attrs import fields, has +from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override class Converter(cattrs.Converter): """Addon to cattr converter.""" - def __init__(self): + def __init__(self, camel_to_snake_case: bool = False, enum_to_value: bool = False): """Initialize function, add in extra unstructure hooks.""" super().__init__() # More from cattrs-extras/blob/master/src/cattrs_extras/converter.py @@ -16,10 +20,57 @@ def __init__(self): self.register_unstructure_hook(Decimal, self._unstructure_decimal) self.register_unstructure_hook(datetime, self._unstructure_datetime) + if enum_to_value: + self.register_structure_hook(Enum, self._structure_enum_value) + + if camel_to_snake_case: + self.register_unstructure_hook_factory( + has, self._to_snake_case_unstructure + ) + self.register_structure_hook_factory( + has, self._to_snake_case_structure + ) + + def _to_snake_case(self, camel_str: str) -> str: + return re.sub(r'(? str: + components = snake_str.split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + def _to_snake_case_unstructure(self, cls): + return make_dict_unstructure_fn( + cls, + self, + **{ + a.name: override(rename=self._to_snake_case(a.name)) + for a in fields(cls) + } + ) + + def _to_snake_case_structure(self, cls): + # When structuring the target classes attribute is used for look up on the source, so we need to convert it + # to camel case. + return make_dict_structure_fn( + cls, + self, + **{ + a.name: override(rename=self._to_camel_case(a.name)) + for a in fields(cls) + } + ) + @staticmethod def _structure_decimal(obj: Any, cls: Type) -> Decimal: return cls(str(obj)) + @staticmethod + def _structure_enum_value(obj: Any, cls: Type): + if not issubclass(cls, Enum): + return None + # Enum automatically comes in as the value here, just return it + return obj + @staticmethod def _unstructure_decimal(obj: Decimal) -> float: return float(obj or 0) diff --git a/pay-api/tests/unit/api/test_partial_refund.py b/pay-api/tests/unit/api/test_partial_refund.py new file mode 100644 index 000000000..abcdf2993 --- /dev/null +++ b/pay-api/tests/unit/api/test_partial_refund.py @@ -0,0 +1,204 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to assure the refund end-point can handle partial refunds. + +Test-Suite to ensure that the refunds endpoint for partials is working as expected. +""" +import json +from typing import List +from unittest.mock import patch + +from _decimal import Decimal + +from pay_api.models import Invoice as InvoiceModel +from pay_api.models import PaymentLineItem as PaymentLineItemModel +from pay_api.models import Refund as RefundModel +from pay_api.models import RefundPartialLine +from pay_api.models import RefundsPartial as RefundPartialModel +from pay_api.services.direct_pay_service import DirectPayService +from pay_api.services.refund import RefundService +from pay_api.utils.constants import REFUND_SUCCESS_MESSAGES +from pay_api.utils.enums import InvoiceStatus, RefundsPartialType, Role +from pay_api.utils.errors import Error +from tests.utilities.base_test import get_claims, get_payment_request, token_header + + +def test_create_refund(session, client, jwt, app, stan_server, monkeypatch): + """Assert that the endpoint returns 202.""" + token = jwt.create_jwt(get_claims(app_request=app), token_header) + headers = {'Authorization': f'Bearer {token}', 'content-type': 'application/json'} + + rv = client.post('/api/v1/payment-requests', data=json.dumps(get_payment_request()), + headers=headers) + inv_id = rv.json.get('id') + invoice: InvoiceModel = InvoiceModel.find_by_id(inv_id) + invoice.invoice_status_code = InvoiceStatus.PAID.value + invoice.save() + + data = { + 'clientSystemUrl': 'http://localhost:8080/coops-web/transactions/transaction_id=abcd', + 'payReturnUrl': 'http://localhost:8080/pay-web' + } + receipt_number = '123451' + rv = client.post(f'/api/v1/payment-requests/{inv_id}/transactions', data=json.dumps(data), + headers=headers) + txn_id = rv.json.get('id') + client.patch(f'/api/v1/payment-requests/{inv_id}/transactions/{txn_id}', + data=json.dumps({'receipt_number': receipt_number}), headers=headers) + + token = jwt.create_jwt(get_claims(app_request=app, role=Role.SYSTEM.value), token_header) + headers = {'Authorization': f'Bearer {token}', 'content-type': 'application/json'} + + payment_line_items: List[PaymentLineItemModel] = invoice.payment_line_items + refund_amount = float(payment_line_items[0].filing_fees / 2) + refund_revenue = [{'paymentLineItemId': payment_line_items[0].id, + 'refundAmount': refund_amount, + 'refundType': RefundsPartialType.OTHER_FEES.value} + ] + + direct_pay_service = DirectPayService() + base_paybc_response = _get_base_paybc_response() + refund_partial = [ + RefundPartialLine(payment_line_item_id=payment_line_items[0].id, + refund_amount=Decimal(refund_amount), + refund_type=RefundsPartialType.OTHER_FEES.value) + ] + with patch('pay_api.services.direct_pay_service.DirectPayService.get') as mock_get: + mock_get.return_value.ok = True + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = base_paybc_response + payload = direct_pay_service.build_automated_refund_payload(invoice, refund_partial) + assert payload + assert payload['txnAmount'] == refund_partial[0].refund_amount + assert payload['refundRevenue'][0]['lineNumber'] == '1' + assert payload['refundRevenue'][0]['refundAmount'] == refund_partial[0].refund_amount + + rv = client.post(f'/api/v1/payment-requests/{inv_id}/refunds', + data=json.dumps({'reason': 'Test', + 'refundRevenue': refund_revenue + }), + headers=headers) + assert rv.status_code == 202 + assert rv.json.get('message') == REFUND_SUCCESS_MESSAGES['DIRECT_PAY.PAID'] + assert RefundModel.find_by_invoice_id(inv_id) is not None + + refunds_partial: List[RefundPartialModel] = RefundService.get_refund_partials_by_invoice_id(inv_id) + assert refunds_partial + assert len(refunds_partial) == 1 + + refund = refunds_partial[0] + assert refund.id is not None + assert refund.payment_line_item_id == payment_line_items[0].id + assert refund.refund_amount == refund_amount + assert refund.refund_type == RefundsPartialType.OTHER_FEES.value + + +def test_create_refund_fails(session, client, jwt, app, stan_server, monkeypatch): + """Assert that the endpoint returns 400.""" + token = jwt.create_jwt(get_claims(app_request=app), token_header) + headers = {'Authorization': f'Bearer {token}', 'content-type': 'application/json'} + + rv = client.post('/api/v1/payment-requests', data=json.dumps(get_payment_request()), headers=headers) + inv_id = rv.json.get('id') + + data = { + 'clientSystemUrl': 'http://localhost:8080/coops-web/transactions/transaction_id=abcd', + 'payReturnUrl': 'http://localhost:8080/pay-web' + } + receipt_number = '123451' + rv = client.post(f'/api/v1/payment-requests/{inv_id}/transactions', data=json.dumps(data), + headers=headers) + txn_id = rv.json.get('id') + client.patch(f'/api/v1/payment-requests/{inv_id}/transactions/{txn_id}', + data=json.dumps({'receipt_number': receipt_number}), headers=headers) + + invoice = InvoiceModel.find_by_id(inv_id) + invoice.invoice_status_code = InvoiceStatus.APPROVED.value + invoice.save() + + payment_line_items: List[PaymentLineItemModel] = invoice.payment_line_items + refund_amount = float(payment_line_items[0].filing_fees / 2) + refund_revenue = [{'paymentLineItemId': payment_line_items[0].id, + 'refundAmount': refund_amount, + 'refundType': RefundsPartialType.OTHER_FEES.value} + ] + + token = jwt.create_jwt(get_claims(app_request=app, role=Role.SYSTEM.value), token_header) + headers = {'Authorization': f'Bearer {token}', 'content-type': 'application/json'} + rv = client.post(f'/api/v1/payment-requests/{inv_id}/refunds', + data=json.dumps({'reason': 'Test', + 'refundRevenue': refund_revenue + }), + headers=headers) + assert rv.status_code == 400 + assert rv.json.get('type') == Error.INVALID_REQUEST.name + assert RefundModel.find_by_invoice_id(inv_id) is None + + refunds_partial: List[RefundPartialModel] = RefundService.get_refund_partials_by_invoice_id(inv_id) + assert not refunds_partial + assert len(refunds_partial) == 0 + + +def _get_base_paybc_response(): + return { + 'pbcrefnumber': '10007', + 'trnnumber': '1', + 'trndate': '2023-03-06', + 'description': 'Direct_Sale', + 'trnamount': '31.5', + 'paymentmethod': 'CC', + 'currency': 'CAD', + 'gldate': '2023-03-06', + 'paymentstatus': 'CMPLT', + 'trnorderid': '23525252', + 'paymentauthcode': 'TEST', + 'cardtype': 'VI', + 'revenue': [ + { + 'linenumber': '1', + 'revenueaccount': 'None.None.None.None.None.000000.0000', + 'revenueamount': '30', + 'glstatus': 'CMPLT', + 'glerrormessage': None, + 'refund_data': [ + { + 'txn_refund_distribution_id': 103570, + 'revenue_amount': 30, + 'refund_date': '2023-04-15T20:13:36Z', + 'refundglstatus': 'CMPLT', + 'refundglerrormessage': None + } + ] + }, + { + 'linenumber': '2', + 'revenueaccount': 'None.None.None.None.None.000000.0001', + 'revenueamount': '1.5', + 'glstatus': 'CMPLT', + 'glerrormessage': None, + 'refund_data': [ + { + 'txn_refund_distribution_id': 103182, + 'revenue_amount': 1.5, + 'refund_date': '2023-04-15T20:13:36Z', + 'refundglstatus': 'CMPLT', + 'refundglerrormessage': None + } + ] + } + ], + 'postedrefundamount': None, + 'refundedamount': None + }