Skip to content

Commit

Permalink
19760 - Partial Refunds resource input / model persistence
Browse files Browse the repository at this point in the history
  • Loading branch information
ochiu committed Feb 16, 2024
1 parent be3fa2c commit e8179bf
Show file tree
Hide file tree
Showing 8 changed files with 263 additions and 12 deletions.
2 changes: 1 addition & 1 deletion pay-api/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ notes=FIXME,XXX,TODO
ignored-modules=flask_sqlalchemy,sqlalchemy,SQLAlchemy,alembic,scoped_session
ignored-classes=scoped_session
min-similarity-lines=15
disable=C0301,W0511
disable=C0301,W0511,R0903
good-names=
b,
d,
Expand Down
2 changes: 1 addition & 1 deletion pay-api/src/pay_api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
from .payment_transaction import PaymentTransaction, PaymentTransactionSchema
from .receipt import Receipt, ReceiptSchema
from .refund import Refund
from .refunds_partial import RefundsPartial
from .refunds_partial import RefundPartialLine, RefundsPartial
from .routing_slip import RoutingSlip, RoutingSlipSchema
from .routing_slip_status_code import RoutingSlipStatusCode, RoutingSlipStatusCodeSchema
from .statement import Statement, StatementSchema
Expand Down
39 changes: 38 additions & 1 deletion pay-api/src/pay_api/models/refunds_partial.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@
# 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.
"""Model to handle all operations related to Payment Line Item."""
"""Model to handle all operations related to Payment Line Item partial refunds."""

from decimal import Decimal
from attrs import define
from sqlalchemy import ForeignKey

from .payment_line_item import PaymentLineItem
from .audit import Audit
from .base_model import VersionedModel
from .db import db
from ..utils.enums import RefundsPartialType


class RefundsPartial(Audit, VersionedModel): # pylint: disable=too-many-instance-attributes
Expand Down Expand Up @@ -46,3 +51,35 @@ 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()


@define
class RefundPartialLine:
"""Used to feed for partial refunds."""

payment_line_item_id: int
refund_amount: Decimal
refund_type: RefundsPartialType

@classmethod
def from_row(cls, row: RefundsPartial):
"""From row is used so we don't tightly couple to our database class.
https://www.attrs.org/en/stable/init.html
"""
return cls(payment_line_item_id=row.payment_line_item_id,
refund_amount=row.refund_amount,
refund_type=row.refund_type)
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": {
"paymentLineItemId": {
"type": "integer"
},
"refundAmount": {
"type": "number"
},
"refundType": {
"type": "string"
}
},
"required": ["paymentLineItemId", "refundAmount", "refundType"]
}
}
}
}
}
30 changes: 28 additions & 2 deletions pay-api/src/pay_api/services/refund.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,22 @@
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 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
Expand Down Expand Up @@ -268,12 +272,34 @@ 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.payment_line_item_id,
refund_amount=line.refund_amount,
refund_type=line.refund_type.value
)
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).structure(refund_revenue, List[RefundPartialLine])
52 changes: 49 additions & 3 deletions pay-api/src/pay_api/utils/converter.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,70 @@
"""Converter module to support decimal and datetime serialization."""
from decimal import Decimal
import re
from datetime import datetime
from typing import Any, Dict
from decimal import Decimal
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):
"""Initialize function, add in extra unstructure hooks."""
super().__init__()
# More from cattrs-extras/blob/master/src/cattrs_extras/converter.py
self.register_structure_hook(Decimal, self._structure_decimal)
self.register_unstructure_hook(Decimal, self._unstructure_decimal)
self.register_unstructure_hook(datetime, self._unstructure_datetime)

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'(?<!^)(?=[A-Z])', '_', camel_str).lower()

def _to_camel_case(self, snake_str: str) -> 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 _unstructure_decimal(obj: Decimal) -> float:
return float(obj or 0)

@staticmethod
def _structure_decimal(obj: Any, cls: Type) -> Decimal:
return cls(str(obj))

@staticmethod
def _unstructure_datetime(obj: datetime) -> str:
return obj.isoformat() if obj else None
Expand Down
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'
SERVICE_FEE = 'SERVICE_FEE'
FILING_FEE = 'FILING_FEE'
FUTURE_EFFECTIVE_FEE = 'FUTURE_EFFECTIVE_FEE'
OTHER_FEES = 'OTHER_FEES'


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

0 comments on commit e8179bf

Please sign in to comment.