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
12 changes: 12 additions & 0 deletions docs/docs/PayBC Mocking/paybc-1.0.0.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,24 @@
glstatus: PAID
glerrormessage: null
refundglstatus: PAID
refund_data:
- revenue_amount: 30
refund_date: '2023-04-15T20:13:36Z'
refundglstatus: 'CMPLT'
refundglerrormessage: null
- linenumber: '2'
revenueaccount: 112.32562.20245.4378.3212319.000000.0000
revenueamount: '30'
glstatus: PAID
glerrormessage: null
refundglstatus: PAID
refund_data:
- revenue_amount: 1.5
refund_date: '2023-04-15T20:13:36Z'
refundglstatus: 'CMPLT'
refundglerrormessage: null
postedrefundamount: 31.50
refundedamount: null
'400':
description: BadRequest
content:
Expand Down
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
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 RefundPartialLine, RefundsPartial
from .routing_slip import RoutingSlip, RoutingSlipSchema
from .routing_slip_status_code import RoutingSlipStatusCode, RoutingSlipStatusCodeSchema
from .statement import Statement, StatementSchema
Expand Down
24 changes: 23 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,15 @@
# 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 .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)
8 changes: 5 additions & 3 deletions pay-api/src/pay_api/services/base_payment_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from pay_api.models import PaymentLineItem as PaymentLineItemModel
from pay_api.models import PaymentTransaction as PaymentTransactionModel
from pay_api.models import Receipt as ReceiptModel
from pay_api.models.refunds_partial import RefundPartialLine
from pay_api.services.cfs_service import CFSService
from pay_api.services.invoice import Invoice
from pay_api.services.invoice_reference import InvoiceReference
Expand Down Expand Up @@ -68,13 +69,13 @@ def update_account(self, name: str, cfs_account: CfsAccountModel, # pylint: dis
return None

@abstractmethod
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:
"""Create invoice in payment system."""

def update_invoice(self, # pylint:disable=too-many-arguments,unused-argument
payment_account: PaymentAccount, # pylint: disable=unused-argument
line_items: [PaymentLineItem], invoice_id: int, # pylint: disable=unused-argument
line_items: List[PaymentLineItem], invoice_id: int, # pylint: disable=unused-argument
paybc_inv_number: str, reference_count: int = 0, # pylint: disable=unused-argument
**kwargs):
"""Update invoice in payment system."""
Expand Down Expand Up @@ -103,7 +104,8 @@ def get_payment_system_url_for_payment(self, payment: Payment, # pylint:disable
return None

def process_cfs_refund(self, invoice: InvoiceModel, # pylint:disable=unused-argument
payment_account: PaymentAccount): # pylint:disable=unused-argument
payment_account: PaymentAccount, # pylint:disable=unused-argument
refund_partial: List[RefundPartialLine]): # pylint:disable=unused-argument
"""Process Refund if any."""
return None

Expand Down
8 changes: 5 additions & 3 deletions pay-api/src/pay_api/services/bcol_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"""Service to manage PayBC interaction."""

from datetime import datetime
from typing import Dict
from typing import Dict, List

from flask import current_app
from requests.exceptions import HTTPError
Expand All @@ -23,6 +23,7 @@
from pay_api.models import Invoice as InvoiceModel
from pay_api.models import Payment as PaymentModel
from pay_api.models.corp_type import CorpType
from pay_api.models.refunds_partial import RefundPartialLine
from pay_api.utils.enums import AuthHeaderType, ContentType, PaymentMethod, PaymentStatus
from pay_api.utils.enums import PaymentSystem as PaySystemCode
from pay_api.utils.errors import get_bcol_error
Expand All @@ -47,7 +48,7 @@ def get_payment_system_code(self):
@user_context
@skip_invoice_for_sandbox
def create_invoice(self, payment_account: PaymentAccount, # pylint: disable=too-many-locals
line_items: [PaymentLineItem], invoice: Invoice, **kwargs) -> InvoiceReference:
line_items: List[PaymentLineItem], invoice: Invoice, **kwargs) -> InvoiceReference:
"""Create Invoice in PayBC."""
current_app.logger.debug(f'<Creating BCOL records for Invoice: {invoice.id}, '
f'Auth Account : {payment_account.auth_account_id}')
Expand Down Expand Up @@ -148,7 +149,8 @@ def get_payment_method_code(self):
return PaymentMethod.DRAWDOWN.value

def process_cfs_refund(self, invoice: InvoiceModel,
payment_account: PaymentAccount): # pylint:disable=unused-argument
payment_account: PaymentAccount,
refund_partial: List[RefundPartialLine]): # pylint:disable=unused-argument
"""Process refund in CFS."""
self._publish_refund_to_mailer(invoice)
payment: PaymentModel = PaymentModel.find_payment_for_invoice(invoice.id)
Expand Down
152 changes: 136 additions & 16 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,43 @@
STATUS_PAID = ('PAID', 'CMPLT')


@define
class RefundLineRequest():
"""Refund line from order status query."""

revenue_account: str
refund_amount: Decimal


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

refundglstatus: Optional[PaymentDetailsGlStatus]
refundglerrormessage: str


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

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]
postedrefundamount: Optional[Decimal]
refundedamount: Optional[Decimal]


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

Expand Down Expand Up @@ -97,11 +140,13 @@ def _create_revenue_string(invoice) -> str:
return PAYBC_REVENUE_SEPARATOR.join(revenue_item)

@staticmethod
def _get_gl_coding(distribution_code: DistributionCodeModel, total):
return f'{distribution_code.client}.{distribution_code.responsibility_centre}.' \
def _get_gl_coding(distribution_code: DistributionCodeModel, total, exclude_total=False):
base = f'{distribution_code.client}.{distribution_code.responsibility_centre}.' \
f'{distribution_code.service_line}.{distribution_code.stob}.{distribution_code.project_code}' \
f'.000000.0000' \
f':{format(total, DECIMAL_PRECISION)}'
f'.000000.0000'
if not exclude_total:
base += f':{format(total, DECIMAL_PRECISION)}'
return base

def get_payment_system_code(self):
"""Return DIRECT_PAY as the system code."""
Expand All @@ -111,15 +156,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 All @@ -141,7 +187,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,
refund_partial: List[RefundPartialLine]): # 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 All @@ -152,7 +199,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, refund_partial)
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 +281,92 @@ def _get_refund_token(cls):
return token_response

@staticmethod
def _build_automated_refund_payload(invoice: InvoiceModel):
def _validate_refund_amount(refund_amount, max_amount):
if refund_amount > max_amount:
current_app.logger.error(f'Refund amount {str(refund_amount)} '
f'exceeds maximum allowed amount {str(max_amount)}.')
raise BusinessException(Error.INVALID_REQUEST)

@staticmethod
def _build_refund_revenue(paybc_invoice: OrderStatus, refund_lines: List[RefundLineRequest]):
"""Build PAYBC refund revenue lines for the refund."""
if (paybc_invoice.postedrefundamount or 0) > 0 or (paybc_invoice.refundedamount or 0) > 0:
current_app.logger.error('Refund already detected.')
raise BusinessException(Error.INVALID_REQUEST)

lines = []
for distribution_line in refund_lines:
if distribution_line.refund_amount == 0:
continue
revenue_match = next((r for r in paybc_invoice.revenue
if r.revenueaccount == distribution_line.revenue_account), None)
if revenue_match is None:
current_app.logger.error('Matching distribution code to revenue account not found.')
raise BusinessException(Error.INVALID_REQUEST)
DirectPayService._validate_refund_amount(distribution_line.refund_amount, revenue_match.revenueamount)
lines.append({'lineNumber': revenue_match.linenumber, 'refundAmount': distribution_line.refund_amount})
return lines

@staticmethod
def _build_refund_revenue_lines(refund_partial: List[RefundPartialLine]):
"""Provide refund lines and total for the refund."""
total = Decimal('0')
refund_lines = []
for refund_line in refund_partial:
revenue_account = None
pli = PaymentLineItemModel.find_by_id(refund_line.payment_line_item_id)
if not pli or refund_line.refund_amount < 0:
raise BusinessException(Error.INVALID_REQUEST)
fee_distribution = DistributionCodeModel.find_by_id(pli.fee_distribution_id)
if refund_line.refund_type != RefundsPartialType.SERVICE_FEE.value:
DirectPayService._validate_refund_amount(refund_line.refund_amount, pli.total)
revenue_account = DirectPayService._get_gl_coding(fee_distribution,
refund_line.refund_amount,
exclude_total=True)
elif refund_line.refund_type == RefundsPartialType.SERVICE_FEE.value:
DirectPayService._validate_refund_amount(refund_line.refund_amount, pli.service_fees)
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_line.refund_amount,
exclude_total=True)
refund_lines.append(RefundLineRequest(revenue_account, refund_line.refund_amount))
total += refund_line.refund_amount
return refund_lines, total

@classmethod
def _query_order_status(cls, invoice: InvoiceModel) -> OrderStatus:
"""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 = cls.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

@classmethod
def build_automated_refund_payload(cls, 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_revenue_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
Loading
Loading