Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

19760 - initial partial refunds model persistence #1415

Merged
merged 5 commits into from
Feb 16, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -16,6 +16,8 @@
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
Expand Down Expand Up @@ -50,6 +52,19 @@ class RefundsPartial(Audit, VersionedModel): # pylint: disable=too-many-instanc
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()
Copy link
Collaborator

@seeker25 seeker25 Feb 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably better to put this in the service and keep the models barebones?

You reference to payment_line_item in the model, which could cause some pain of circular dependencies in the future

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, good call


@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:
Expand Down
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,14 +272,36 @@ 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
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
)
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])
55 changes: 53 additions & 2 deletions pay-api/src/pay_api/utils/converter.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,76 @@
"""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):
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably convert this into a data class for ConverterOptions if we start adding more configurations in the future

"""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 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'(?<!^)(?=[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 _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)
Expand Down
Loading
Loading