Skip to content

Commit

Permalink
19760 - initial partial refunds model persistence
Browse files Browse the repository at this point in the history
  • Loading branch information
ochiu committed Feb 15, 2024
1 parent be3fa2c commit d83d853
Show file tree
Hide file tree
Showing 7 changed files with 228 additions and 7 deletions.
27 changes: 27 additions & 0 deletions pay-api/src/pay_api/models/dataclass.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 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.
"""This module holds data classes."""
from dataclasses import dataclass
from _decimal import Decimal

from pay_api.utils.enums import RefundsPartialType


@dataclass
class RefundPartialLine:
"""Used for processing partial refund records."""

line_item_id: int
refund_amount: Decimal
refund_type: RefundsPartialType
15 changes: 15 additions & 0 deletions pay-api/src/pay_api/models/refunds_partial.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"""Model to handle all operations related to Payment Line Item."""

from sqlalchemy import ForeignKey

from . import PaymentLineItem
from .audit import Audit
from .base_model import VersionedModel
from .db import db
Expand Down Expand Up @@ -46,3 +48,16 @@ class RefundsPartial(Audit, VersionedModel): # pylint: disable=too-many-instanc
payment_line_item_id = db.Column(db.Integer, ForeignKey('payment_line_items.id'), nullable=False, index=True)
refund_amount = db.Column(db.Numeric(19, 2), nullable=False)
refund_type = db.Column(db.String(50), nullable=True)

@classmethod
def find_by_invoice_id(cls, invoice_id: int):
"""Return refund partials by invoice id."""
return db.session.query(RefundsPartial)\
.join(PaymentLineItem, PaymentLineItem.id == RefundsPartial.payment_line_item_id)\
.filter(PaymentLineItem.invoice_id == invoice_id).all()

@classmethod
def find_by_payment_line_item_id(cls, payment_line_item_id: int):
"""Return refund partials by payment line item id."""
return db.session.query(RefundsPartial) \
.filter(PaymentLineItem.id == payment_line_item_id).all()
20 changes: 19 additions & 1 deletion pay-api/src/pay_api/schemas/schemas/refund.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@
"examples": [
"Duplicate Invoice"
]
},
"refundRevenue": {
"type": "array",
"items": {
"type": "object",
"properties": {
"lineItemId": {
"type": "integer"
},
"refundAmount": {
"type": "number"
},
"refundType": {
"type": "string"
}
},
"required": ["lineItemId", "refundAmount", "refundType"]
}
}
}
}
}
5 changes: 4 additions & 1 deletion pay-api/src/pay_api/services/direct_pay_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# limitations under the License.
"""Service to manage Direct Pay PAYBC Payments."""
import base64
from typing import List
from urllib.parse import unquote_plus, urlencode

from dateutil import parser
Expand All @@ -33,6 +34,7 @@
from pay_api.utils.util import current_local_time, generate_transaction_number, parse_url_params

from ..exceptions import BusinessException
from ..models.dataclass import RefundPartialLine
from ..utils.errors import Error
from ..utils.paybc_transaction_error_message import PAYBC_TRANSACTION_ERROR_MESSAGE_DICT
from .oauth_service import OAuthService
Expand Down Expand Up @@ -141,7 +143,8 @@ def get_pay_system_reason_code(self, pay_response_url: str) -> str: # pylint:di
return None

def process_cfs_refund(self, invoice: InvoiceModel,
payment_account: PaymentAccount): # pylint:disable=unused-argument
payment_account: PaymentAccount,
partial_refund_lines: List[RefundPartialLine] = None): # pylint:disable=unused-argument
"""Process refund in CFS."""
current_app.logger.debug('<process_cfs_refund creating automated refund for invoice: '
f'{invoice.id}, {invoice.invoice_status_code}')
Expand Down
38 changes: 36 additions & 2 deletions pay-api/src/pay_api/services/refund.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@
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 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.dataclass 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
Expand Down Expand Up @@ -268,12 +271,43 @@ 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)
invoice_status = pay_system_service.process_cfs_refund(invoice, payment_account=payment_account)
refund_partial_lines = cls._get_partial_refund_lines(request.get('refundRevenue', None))
invoice_status = pay_system_service.process_cfs_refund(invoice=invoice,
payment_account=payment_account)
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
invoice.refund = invoice.total # no partial refund
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.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 []

refund_lines = []
for refund in refund_revenue:
line = RefundPartialLine(
line_item_id=refund.get('lineItemId'),
refund_amount=refund.get('refundAmount'),
refund_type=refund.get('refundType')
)
refund_lines.append(line)

return refund_lines
4 changes: 1 addition & 3 deletions pay-api/src/pay_api/utils/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,10 +285,8 @@ def from_value(cls, value):
class RefundsPartialType(Enum):
"""Refund partial types."""

PRIORITY_FEE = 'PRIORITY_FEE'
SUB_TOTAL_FEE = 'SUB_TOTAL_FEE' # Filing fee + Priority fee + future effective fees
SERVICE_FEE = 'SERVICE_FEE'
FILING_FEE = 'FILING_FEE'
FUTURE_EFFECTIVE_FEE = 'FUTURE_EFFECTIVE_FEE'


class ReverseOperation(Enum):
Expand Down
126 changes: 126 additions & 0 deletions pay-api/tests/unit/api/test_partial_refund.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# 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 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.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)

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 = [{'lineItemId': payment_line_items[0].id,
'refundAmount': refund_amount,
'refundType': RefundsPartialType.SUB_TOTAL_FEE.value}
]

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] = RefundPartialModel.find_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.SUB_TOTAL_FEE.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 = [{'lineItemId': payment_line_items[0].id,
'refundAmount': refund_amount,
'refundType': RefundsPartialType.SUB_TOTAL_FEE.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] = RefundPartialModel.find_by_invoice_id(inv_id)
assert not refunds_partial
assert len(refunds_partial) == 0

0 comments on commit d83d853

Please sign in to comment.