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

17534 - NSF Implementation #1377

Merged
merged 6 commits into from
Jan 22, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
30 changes: 30 additions & 0 deletions pay-api/migrations/versions/2023_12_05_b65365f7852b_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Add Non-Sufficient Funds table to store Non-Sufficient Funds invoices

Revision ID: b65365f7852b
Revises: eec11500a81e
Create Date: 2023-12-05 12:28:27.025012

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = 'b65365f7852b'
down_revision = 'eec11500a81e'
branch_labels = None
depends_on = None


def upgrade():
op.create_table('non_sufficient_funds',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('description', sa.String(length=50), nullable=True),
sa.Column('invoice_id', sa.Integer(), nullable=False),
Copy link
Collaborator

@seeker25 seeker25 Jan 22, 2024

Choose a reason for hiding this comment

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

invoice_number as well? that's what's really tied to CAS's system

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Originally I felt that passing the invoice_number would the better choice, since the NSF state can apply to multiple invoices, and with the current logic it just fit. But at the end of the day it's tied to a specific invoice, so it made more sense for this. Passing both invoice_number and invoice_id should be fine, even though it's might seem a little redundant.

sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], ),
sa.PrimaryKeyConstraint('id')
)


def downgrade():
op.drop_table('non_sufficient_funds')
1 change: 1 addition & 0 deletions pay-api/src/pay_api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from .invoice_reference_status_code import InvoiceReferenceStatusCode, InvoiceReferenceStatusCodeSchema
from .invoice_status_code import InvoiceStatusCode, InvoiceStatusCodeSchema
from .line_item_status_code import LineItemStatusCode, LineItemStatusCodeSchema
from .non_sufficient_funds import NonSufficientFundsModel, NonSufficientFundsSchema
from .notification_status_code import NotificationStatusCode, NotificationStatusCodeSchema
from .payment import Payment, PaymentSchema
from .payment_account import PaymentAccount, PaymentAccountSchema # noqa: I001
Expand Down
6 changes: 6 additions & 0 deletions pay-api/src/pay_api/models/cfs_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ def find_effective_by_account_id(cls, account_id: str):
return CfsAccount.query.filter(CfsAccount.account_id == account_id,
CfsAccount.status != CfsAccountStatus.INACTIVE.value).one_or_none()

@classmethod
def find_latest_account_by_account_id(cls, account_id: str):
"""Return a frozen account by account_id, and return the record with the highest id."""
Copy link
Collaborator

Choose a reason for hiding this comment

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

comment doesn't pertain to the logic?

return CfsAccount.query.filter(
CfsAccount.account_id == account_id).order_by(CfsAccount.id.desc()).one_or_none()

@classmethod
def find_by_account_id(cls, account_id: str) -> List[CfsAccount]:
"""Return a Account by id."""
Expand Down
65 changes: 65 additions & 0 deletions pay-api/src/pay_api/models/non_sufficient_funds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Copyright © 2019 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.
"""Model to handle all operations related to Non-Sufficient Funds."""
from __future__ import annotations

from attrs import define
from sqlalchemy import ForeignKey

from .base_model import BaseModel
from .db import db


class NonSufficientFundsModel(BaseModel): # pylint: disable=too-many-instance-attributes
"""This class manages all of the base data about Non-Sufficient Funds."""

__tablename__ = 'non_sufficient_funds'
# this mapper is used so that new and old versions of the service can be run simultaneously,
# making rolling upgrades easier
# This is used by SQLAlchemy to explicitly define which fields we're interested
# so it doesn't freak out and say it can't map the structure if other fields are present.
# This could occur from a failed deploy or during an upgrade.
# The other option is to tell SQLAlchemy to ignore differences, but that is ambiguous
# and can interfere with Alembic upgrades.
#
# NOTE: please keep mapper names in alpha-order, easier to track that way
# Exception, id is always first, _fields first
__mapper_args__ = {
'include_properties': [
'id',
'description',
'invoice_id',
]
}

id = db.Column(db.Integer, primary_key=True, autoincrement=True)
description = db.Column(db.String(50), nullable=True)
invoice_id = db.Column(db.Integer, ForeignKey('invoices.id'), nullable=False)


@define
class NonSufficientFundsSchema: # pylint: disable=too-few-public-methods
"""Used to search for NSF records."""

id: int
invoice_id: int
description: str

@classmethod
def from_row(cls, row: NonSufficientFundsModel):
"""From row is used so we don't tightly couple to our database class.

https://www.attrs.org/en/stable/init.html
"""
return cls(id=row.id, invoice_id=row.invoice_id, description=row.description)
6 changes: 4 additions & 2 deletions pay-api/src/pay_api/models/payment_line_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,10 @@ class Meta: # pylint: disable=too-few-public-methods
class PaymentLineItemSearchModel: # pylint: disable=too-few-public-methods
"""Payment Line Item Search Model."""

total: Decimal
gst: Decimal
pst: Decimal
service_fees: Decimal
description: str
filing_type_code: str

Expand All @@ -121,5 +123,5 @@ def from_row(cls, row: PaymentLineItem):

https://www.attrs.org/en/stable/init.html
"""
return cls(gst=row.gst, pst=row.pst, description=row.description,
filing_type_code=row.fee_schedule.filing_type_code)
return cls(total=row.total, gst=row.gst, pst=row.pst, service_fees=row.service_fees,
Copy link
Collaborator

Choose a reason for hiding this comment

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

If we're changing this, need to let CSO know

description=row.description, filing_type_code=row.fee_schedule.filing_type_code)
2 changes: 2 additions & 0 deletions pay-api/src/pay_api/resources/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from .invoice_receipt import bp as invoice_receipt_bp
from .invoices import bp as invoices_bp
from .meta import bp as meta_bp
from .non_sufficient_funds import bp as non_sufficient_funds_bp
from ..ops import bp as ops_bp
from .payment import bp as payment_bp
from .refund import bp as refund_bp
Expand Down Expand Up @@ -66,6 +67,7 @@ def init_app(self, app):
self.app.register_blueprint(invoices_bp)
self.app.register_blueprint(invoice_receipt_bp)
self.app.register_blueprint(meta_bp)
self.app.register_blueprint(non_sufficient_funds_bp)
self.app.register_blueprint(ops_bp)
self.app.register_blueprint(payment_bp)
self.app.register_blueprint(refund_bp)
Expand Down
67 changes: 67 additions & 0 deletions pay-api/src/pay_api/resources/v1/non_sufficient_funds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Copyright © 2019 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.
"""Resource for Account Non-Sufficient Funds endpoints."""
from http import HTTPStatus

from flask import Blueprint, Response, current_app, jsonify
from flask_cors import cross_origin

from pay_api.exceptions import BusinessException
from pay_api.services import NonSufficientFundsService
from pay_api.services.auth import check_auth
from pay_api.utils.auth import jwt as _jwt
from pay_api.utils.constants import EDIT_ROLE, MAKE_PAYMENT, VIEW_ROLE
from pay_api.utils.endpoints_enums import EndpointEnum
from pay_api.utils.trace import tracing as _tracing


bp = Blueprint('NON_SUFFICIENT_FUNDS', __name__,
url_prefix=f'{EndpointEnum.API_V1.value}/accounts/<string:account_id>/nsf')


@bp.route('', methods=['GET', 'OPTIONS'])
@cross_origin(origins='*', methods=['GET'])
@_tracing.trace()
@_jwt.requires_auth
def get_non_sufficient_funds(account_id: str):
"""Get non sufficient funds."""
current_app.logger.info('<get_non_sufficient_funds')
# Check if user is authorized to perform this action
check_auth(business_identifier=None, account_id=account_id, one_of_roles=[MAKE_PAYMENT, EDIT_ROLE, VIEW_ROLE])
response, status = NonSufficientFundsService.find_all_non_sufficient_funds_invoices(
account_id=account_id), HTTPStatus.OK
current_app.logger.debug('>get_non_sufficient_funds')
return jsonify(response), status


@bp.route('/statement', methods=['POST', 'OPTIONS'])
@cross_origin(origins='*', methods=['POST'])
@_tracing.trace()
@_jwt.requires_auth
def get_non_sufficient_funds_statement_pdf(account_id: str):
"""Get non sufficient funds statement pdf."""
current_app.logger.info('<get_non_sufficient_funds_statement_pdf')
# Check if user is authorized to perform this action
check_auth(business_identifier=None, account_id=account_id, one_of_roles=[MAKE_PAYMENT, EDIT_ROLE, VIEW_ROLE])
try:
pdf, pdf_filename = NonSufficientFundsService.create_non_sufficient_funds_statement_pdf(account_id=account_id)
response = Response(pdf, 201)
response.headers.set('Content-Disposition', 'attachment', filename=f'{pdf_filename}.pdf')
Copy link
Collaborator

Choose a reason for hiding this comment

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

candidate for helper function, I think this code is duplicated somewhere else

response.headers.set('Content-Type', 'application/pdf')
response.headers.set('Access-Control-Expose-Headers', 'Content-Disposition')
current_app.logger.debug('>get_non_sufficient_funds_statement')
return response
except BusinessException as exception:
current_app.logger.debug('>get_non_sufficient_funds_statement_pdf')
return exception.response()
1 change: 1 addition & 0 deletions pay-api/src/pay_api/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .hashing import HashingService
from .internal_pay_service import InternalPayService
from .invoice import Invoice as InvoiceService
from .non_sufficient_funds import NonSufficientFundsService
from .payment import Payment
from .payment_service import PaymentService
from .payment_transaction import PaymentTransaction as TransactionService
Expand Down
158 changes: 158 additions & 0 deletions pay-api/src/pay_api/services/non_sufficient_funds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Copyright © 2019 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.
"""Service to manage Non-Sufficient Funds."""
from __future__ import annotations

from datetime import datetime

from flask import current_app

from sqlalchemy import case, func

from pay_api.models import CfsAccount as CfsAccountModel
from pay_api.models import Invoice as InvoiceModel
from pay_api.models import InvoiceReference as InvoiceReferenceModel
from pay_api.models import InvoiceSearchModel, NonSufficientFundsModel, NonSufficientFundsSchema
from pay_api.models import PaymentAccount as PaymentAccountModel
from pay_api.models import PaymentLineItem as PaymentLineItemModel
from pay_api.models import db
from pay_api.utils.converter import Converter
from pay_api.utils.enums import AuthHeaderType, ContentType, InvoiceReferenceStatus, ReverseOperation
from pay_api.utils.user_context import user_context

from .oauth_service import OAuthService


class NonSufficientFundsService:
"""Service to manage Non-Sufficient Funds related operations."""

def __init__(self):
"""Initialize the service."""
self.dao = NonSufficientFundsModel()

def asdict(self):
"""Return the EFT Short name as a python dict."""
return Converter().unstructure(NonSufficientFundsSchema.from_row(self.dao))

@staticmethod
def populate(value: NonSufficientFundsModel):
"""Populate Non-Sufficient Funds Service."""
non_sufficient_funds_service = NonSufficientFundsService()
non_sufficient_funds_service.dao = value
return non_sufficient_funds_service

@staticmethod
def save_non_sufficient_funds(invoice_id: int, description: str) -> NonSufficientFundsService:
"""Create Non-Sufficient Funds record."""
current_app.logger.debug('<save_non_sufficient_funds')
non_sufficient_funds_service = NonSufficientFundsService()

non_sufficient_funds_service.dao.invoice_id = invoice_id
non_sufficient_funds_service.dao.description = description
non_sufficient_funds_dao = non_sufficient_funds_service.dao.save()

non_sufficient_funds_service = NonSufficientFundsService.populate(non_sufficient_funds_dao)
current_app.logger.debug('>save_non_sufficient_funds')
return NonSufficientFundsService.asdict(non_sufficient_funds_service)

@staticmethod
def query_all_non_sufficient_funds_invoices(account_id: str):
"""Return all Non-Sufficient Funds invoices and their aggregate amounts."""
query = (db.session.query(
InvoiceModel, InvoiceReferenceModel)
.join(InvoiceReferenceModel, InvoiceReferenceModel.invoice_id == InvoiceModel.id)
.outerjoin(NonSufficientFundsModel, NonSufficientFundsModel.invoice_id == InvoiceModel.id)
.join(PaymentAccountModel, PaymentAccountModel.id == InvoiceModel.payment_account_id)
.filter(PaymentAccountModel.auth_account_id == account_id)
.group_by(InvoiceModel.id, InvoiceReferenceModel.id)
)

totals_query = (db.session.query(
func.sum(InvoiceModel.total - InvoiceModel.paid).label('total_amount_remaining'),
func.max(case(
[(PaymentLineItemModel.description == ReverseOperation.NSF.value, PaymentLineItemModel.total)],
else_=0))
.label('nsf_amount'),
func.sum(InvoiceModel.total - case(
[(PaymentLineItemModel.description == ReverseOperation.NSF.value, PaymentLineItemModel.total)],
else_=0)).label('total_amount'))
.join(PaymentAccountModel, PaymentAccountModel.id == InvoiceModel.payment_account_id)
.join(PaymentLineItemModel, PaymentLineItemModel.invoice_id == InvoiceModel.id)
.filter(PaymentAccountModel.auth_account_id == account_id)
)

aggregate_totals = totals_query.one()
results = query.all()
total = len(results)

return results, total, aggregate_totals

@staticmethod
def find_all_non_sufficient_funds_invoices(account_id: str):
"""Return all Non-Sufficient Funds invoices."""
results, total, aggregate_totals = NonSufficientFundsService.query_all_non_sufficient_funds_invoices(
account_id=account_id)
invoice_search_model = [InvoiceSearchModel.from_row(invoice_dao) for invoice_dao, _ in results]
converter = Converter()
invoice_list = converter.unstructure(invoice_search_model)
new_invoices = [converter.remove_nones(invoice_dict) for invoice_dict in invoice_list]

data = {
'total': total,
'invoices': new_invoices,
'total_amount': float(aggregate_totals.total_amount),
'total_amount_remaining': float(aggregate_totals.total_amount_remaining),
'nsf_amount': float(aggregate_totals.nsf_amount)
}

return data

@staticmethod
@user_context
def create_non_sufficient_funds_statement_pdf(account_id: str, **kwargs):
"""Create Non-Sufficient Funds statement pdf."""
current_app.logger.debug('<generate_non_sufficient_funds_statement_pdf')
invoice = NonSufficientFundsService.find_all_non_sufficient_funds_invoices(account_id=account_id)
cfs_account: CfsAccountModel = CfsAccountModel.find_latest_account_by_account_id(account_id)
invoice_reference: InvoiceReferenceModel = InvoiceReferenceModel.find_by_invoice_id_and_status(
invoice['invoices'][0]['id'], InvoiceReferenceStatus.ACTIVE.value)
account_url = current_app.config.get('AUTH_API_ENDPOINT') + f'orgs/{account_id}'
account = OAuthService.get(
endpoint=account_url, token=kwargs['user'].bearer_token,
auth_header_type=AuthHeaderType.BEARER, content_type=ContentType.JSON).json()
template_vars = {
'suspendedOn': datetime.strptime(account['suspendedOn'], '%Y-%m-%dT%H:%M:%S%z').strftime('%B %-d, %Y'),
'accountNumber': cfs_account.cfs_account,
'businessName': account['businessName'],
'totalAmountRemaining': invoice['total_amount_remaining'],
'totalAmount': invoice['total_amount'],
'nsfAmount': invoice['nsf_amount'],
'invoices': invoice['invoices'],
'invoiceNumber': invoice_reference.invoice_number,
}

invoice_pdf_dict = {
'templateName': 'non_sufficient_funds',
'reportName': 'non_sufficient_funds',
'templateVars': template_vars,
'populatePageNumber': True
}
current_app.logger.debug('Invoice PDF Dict %s', invoice_pdf_dict)

pdf_response = OAuthService.post(current_app.config.get('REPORT_API_BASE_URL'),
kwargs['user'].bearer_token, AuthHeaderType.BEARER,
ContentType.JSON, invoice_pdf_dict)
current_app.logger.debug('<OAuthService responded to generate_non_sufficient_funds_statement_pdf')

return pdf_response, invoice_pdf_dict.get('reportName')
Loading
Loading