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

19759 - Partial refunds PAYBC implementation #1414

Merged
merged 15 commits into from
Feb 16, 2024
4 changes: 2 additions & 2 deletions pay-api/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ universal = 1
test = pytest

[flake8]
ignore = I001, I003, I004, E126, W504
ignore = I001, I003, I004, E126, W504, R0903
exclude = .git,*migrations*
max-line-length = 120
docstring-min-length=10
Expand Down 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
Copy link
Collaborator Author

@seeker25 seeker25 Feb 15, 2024

Choose a reason for hiding this comment

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

Too few public methods warning, it's fairly useless for dataclasses etc ATTRS and CATTRS don't use pylint anymore they use Flake8 I believe

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 RefundsPartial, RefundPartialLine
from .routing_slip import RoutingSlip, RoutingSlipSchema
from .routing_slip_status_code import RoutingSlipStatusCode, RoutingSlipStatusCodeSchema
from .statement import Statement, StatementSchema
Expand Down
22 changes: 22 additions & 0 deletions pay-api/src/pay_api/models/refunds_partial.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@
# limitations under the License.
"""Model to handle all operations related to Payment Line Item."""

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
from ..utils.enums import RefundsPartialType


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


@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)
123 changes: 112 additions & 11 deletions pay-api/src/pay_api/services/direct_pay_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,29 @@
# limitations under the License.
"""Service to manage Direct Pay PAYBC Payments."""
import base64
from decimal import Decimal
from typing import List, Optional
from urllib.parse import unquote_plus, urlencode

from attrs import define
from dateutil import parser
from flask import current_app

from pay_api.models import Invoice as InvoiceModel
from pay_api.models import InvoiceReference as InvoiceReferenceModel
from pay_api.models import Receipt as ReceiptModel
from pay_api.models import RefundPartialLine
from pay_api.models.distribution_code import DistributionCode as DistributionCodeModel
from pay_api.models.payment_line_item import PaymentLineItem as PaymentLineItemModel
from pay_api.services.base_payment_system import PaymentSystemService
from pay_api.services.hashing import HashingService
from pay_api.services.invoice import Invoice
from pay_api.services.invoice_reference import InvoiceReference
from pay_api.services.payment_account import PaymentAccount
from pay_api.utils.converter import Converter
from pay_api.utils.enums import (
AuthHeaderType, ContentType, InvoiceReferenceStatus, InvoiceStatus, PaymentMethod, PaymentSystem)
AuthHeaderType, ContentType, InvoiceReferenceStatus, InvoiceStatus, PaymentDetailsGlStatus, PaymentMethod,
PaymentSystem, RefundsPartialType)
from pay_api.utils.util import current_local_time, generate_transaction_number, parse_url_params

from ..exceptions import BusinessException
Expand All @@ -45,6 +51,34 @@
STATUS_PAID = ('PAID', 'CMPLT')



@define
class RefundData():
"""Refund data from order status query."""

refundglstatus: Optional[PaymentDetailsGlStatus]
refundglerrormessage: str


@define
class RevenueLine():
"""Revenue line from order status query."""

linenumber: str
revenueaccount: str
revenueamount: Decimal
glstatus: str
glerrormessage: Optional[str]
refund_data: List[RefundData]


@define
class OrderStatus():
seeker25 marked this conversation as resolved.
Show resolved Hide resolved
"""Return from order status query from PAYBC."""

revenue: List[RevenueLine]


class DirectPayService(PaymentSystemService, OAuthService):
"""Service to manage internal payment."""

Expand Down Expand Up @@ -111,15 +145,16 @@ def get_payment_method_code(self):
"""Return DIRECT_PAY as the system code."""
return PaymentMethod.DIRECT_PAY.value

def create_invoice(self, payment_account: PaymentAccount, line_items: [PaymentLineItem], invoice: Invoice,
def create_invoice(self, payment_account: PaymentAccount, line_items: List[PaymentLineItem], invoice: Invoice,
**kwargs) -> InvoiceReference:
"""Return a static invoice number for direct pay."""
invoice_reference: InvoiceReference = InvoiceReference.create(invoice.id,
generate_transaction_number(invoice.id), None)
return invoice_reference

def update_invoice(self, payment_account: PaymentAccount, # pylint:disable=too-many-arguments
line_items: [PaymentLineItem], invoice_id: int, paybc_inv_number: str, reference_count: int = 0,
line_items: List[PaymentLineItem], invoice_id: int, paybc_inv_number: str,
reference_count: int = 0,
**kwargs):
"""Do nothing as direct payments cannot be updated as it will be completed on creation."""
invoice = {
Expand Down Expand Up @@ -152,7 +187,7 @@ def process_cfs_refund(self, invoice: InvoiceModel,

refund_url = current_app.config.get('PAYBC_DIRECT_PAY_CC_REFUND_BASE_URL') + '/paybc-service/api/refund'
access_token: str = self._get_refund_token().json().get('access_token')
data = self._build_automated_refund_payload(invoice)
data = self._build_automated_refund_payload(invoice, None) # TODO add to params.
refund_response = self.post(refund_url, access_token, AuthHeaderType.BEARER,
ContentType.JSON, data, auth_header_name='Bearer-Token').json()
# Check if approved is 1=Success
Expand Down Expand Up @@ -234,19 +269,85 @@ def _get_refund_token(cls):
return token_response

@staticmethod
def _build_automated_refund_payload(invoice: InvoiceModel):
def _build_refund_revenue(paybc_invoice, refund_lines):
"""Build PAYBC refund revenue lines for the refund."""
if paybc_invoice.postedrefundamount > 0 or paybc_invoice.refundedamount > 0:
raise BusinessException('Already refunded.')

lines = []
for distribution_line in refund_lines:
revenue_match = next((r for r in paybc_invoice.revenue
if r.revenueaccount == distribution_line.revenueaccount), None)
if revenue_match is None:
raise BusinessException('Matching distribution code to revenue account not found.')
if revenue_match.revenueamount < distribution_line.refund_amount:
raise BusinessException(f'Refund amount {1} requested exceeds revenueamount {2} in PAYBC.')
lines.append({'lineNumber': revenue_match.linenumber, 'refundAmount': distribution_line.refund_amount})
return lines

@staticmethod
def _build_refund_distribution_lines(refund_partial: List[RefundPartialLine]):
"""Provide distribution lines for the refund."""
total = Decimal('0')
refund_lines = []
for refund in refund_partial:
revenue_account = None
if not (pli := PaymentLineItemModel.find_by_id(refund.payment_line_item_id)):
raise BusinessException('PLI not found.')
if refund.refund_amount < 0:
raise BusinessException('Refund amount cannot be negative.')
revenue_account = pli.fee_distribution_id
fee_distribution = DistributionCodeModel.find_by_id(pli.fee_distribution_id)
if refund.refund_type != RefundsPartialType.SERVICE_FEE.value:
if refund.refund_amount > pli.total:
raise BusinessException('Refund amount { } exceeds pli total in SBC-PAY {}.')
revenue_account = DirectPayService._get_gl_coding(fee_distribution, refund.refund_amount)
elif refund.refund_type == RefundsPartialType.SERVICE_FEE.value:
if refund.refund_amount > pli.service_fees:
raise BusinessException('Refund amount { } exceeds service_fees amount in SBC-PAY {}.')
service_fee_dist_id = fee_distribution.service_fee_distribution_code_id
service_fee_distribution = DistributionCodeModel.find_by_id(service_fee_dist_id)
revenue_account = DirectPayService._get_gl_coding(service_fee_distribution, refund.refund_amount)
refund_lines.append({
'revenueaccount': revenue_account,
'refundAmount': refund.amount
})
total += refund.amount
return refund_lines, total

@staticmethod
def _query_order_status(invoice: InvoiceModel) -> OrderStatus:
seeker25 marked this conversation as resolved.
Show resolved Hide resolved
"""Request invoice order status from PAYBC."""
access_token: str = DirectPayService().get_token().json().get('access_token')
paybc_ref_number: str = current_app.config.get('PAYBC_DIRECT_PAY_REF_NUMBER')
paybc_svc_base_url = current_app.config.get('PAYBC_DIRECT_PAY_BASE_URL')
completed_reference = list(
filter(lambda reference: (reference.status_code == InvoiceReferenceStatus.COMPLETED.value),
invoice.references))[0]
payment_url: str = \
f'{paybc_svc_base_url}/paybc/payment/{paybc_ref_number}/{completed_reference.invoice_number}'
payment_response = OAuthService.get(payment_url, access_token, AuthHeaderType.BEARER, ContentType.JSON).json()
return Converter().structure(payment_response, OrderStatus)
seeker25 marked this conversation as resolved.
Show resolved Hide resolved

@staticmethod
def _build_automated_refund_payload(invoice: InvoiceModel, refund_partial: List[RefundPartialLine]):
"""Build payload to create a refund in PAYBC."""
receipt = ReceiptModel.find_by_invoice_id_and_receipt_number(invoice_id=invoice.id)
invoice_reference = InvoiceReferenceModel.find_by_invoice_id_and_status(
invoice.id, InvoiceReferenceStatus.COMPLETED.value)
# Future: Partial refund support - This is backwards compatible
# refundRevenue: [{
# 'lineNumber': 1,
# 'refundAmount': 50.00,
# }]
return {
refund_payload = {
'orderNumber': int(receipt.receipt_number),
'pbcRefNumber': current_app.config.get('PAYBC_DIRECT_PAY_REF_NUMBER'),
'txnAmount': invoice.total,
'txnNumber': invoice_reference.invoice_number
}
if not refund_partial:
return refund_payload

refund_lines, total_refund = DirectPayService._build_refund_distribution_lines(refund_partial)
paybc_invoice = DirectPayService._query_order_status(invoice)
refund_payload.update({
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I probably could have used a dataclass for this, but I think i'll leave it for now

'refundRevenue': DirectPayService._build_refund_revenue(paybc_invoice, refund_lines),
'txnAmount': total_refund
})
return refund_payload
7 changes: 6 additions & 1 deletion pay-api/src/pay_api/utils/converter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Converter module to support decimal and datetime serialization."""
from decimal import Decimal
from datetime import datetime
from typing import Any, Dict
from typing import Any, Dict, Type
import cattrs


Expand All @@ -12,9 +12,14 @@ def __init__(self):
"""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)

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

@staticmethod
def _unstructure_decimal(obj: Decimal) -> float:
return float(obj or 0)
Expand Down
9 changes: 9 additions & 0 deletions pay-api/src/pay_api/utils/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,3 +346,12 @@ class MessageType(Enum):
PAD_ACCOUNT_CREATE = 'bc.registry.payment.padAccountCreate'
NSF_LOCK_ACCOUNT = 'bc.registry.payment.lockAccount'
NSF_UNLOCK_ACCOUNT = 'bc.registry.payment.unlockAccount'


class PaymentDetailsGlStatus(Enum):
"""Payment details GL status."""

PAID = 'PAID'
INPRG = 'INPRG'
RJCT = 'RJCT' # Should have refundglerrormessage
CMPLT = 'CMPLT'
30 changes: 29 additions & 1 deletion pay-api/tests/unit/services/test_direct_pay_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@
from pay_api.exceptions import BusinessException
from pay_api.models import DistributionCode as DistributionCodeModel
from pay_api.models import FeeSchedule
from pay_api.services.direct_pay_service import DECIMAL_PRECISION, PAYBC_DATE_FORMAT, DirectPayService
from pay_api.services.direct_pay_service import DECIMAL_PRECISION, PAYBC_DATE_FORMAT, DirectPayService, OrderStatus
from pay_api.services.distribution_code import DistributionCode
from pay_api.services.hashing import HashingService
from pay_api.utils.converter import Converter
from pay_api.utils.enums import InvoiceReferenceStatus, InvoiceStatus
from pay_api.utils.errors import Error
from pay_api.utils.util import current_local_time, generate_transaction_number
Expand Down Expand Up @@ -259,3 +260,30 @@ def test_process_cfs_refund_duplicate_refund(monkeypatch):
with pytest.raises(BusinessException) as excinfo:
direct_pay_service.process_cfs_refund(invoice, payment_account)
assert excinfo.value.code == Error.DIRECT_PAY_INVALID_RESPONSE.name


def test_invoice_status_deserialization():
"""Assert our converter is working for OrderStatus."""
paybc_response = {
'revenue': [
{
'linenumber': '1',
'revenueaccount': '112.32363.34725.4375.3200062.000000.0000',
'revenueamount': '130',
'glstatus': 'CMPLT',
'glerrormessage': None,
'refund_data': []
},
{
'linenumber': '2',
'revenueaccount': '112.32363.34725.4375.3200054.000000.0000',
'revenueamount': '1.5',
'glstatus': 'CMPLT',
'glerrormessage': None,
'refund_data': [],
'not_part_of_spec': 'heyey'
}
]
}
paybc_order_status = Converter().structure(paybc_response, OrderStatus)
assert paybc_order_status
Loading