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 - New NSF resource implementation #1341

Closed
wants to merge 41 commits into from
Closed
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
93390b7
Non sufficient funds implementation
rodrigo-barraza Dec 4, 2023
6c7e6c0
Merge branch 'main' into feature/17534
rodrigo-barraza Dec 4, 2023
bb0ccdd
Linting and test fix
rodrigo-barraza Dec 4, 2023
01aa77e
Lint and test fixes
rodrigo-barraza Dec 4, 2023
40a4b47
Flake8 fixes
rodrigo-barraza Dec 4, 2023
df55123
More flake8 test fixes
rodrigo-barraza Dec 4, 2023
7f663da
Fixes
rodrigo-barraza Dec 4, 2023
04742b8
isort fix
rodrigo-barraza Dec 4, 2023
e7cc660
import fix
rodrigo-barraza Dec 4, 2023
b7c6a93
New NSF template, adding search model, migration script, cleanup
rodrigo-barraza Dec 7, 2023
a9fa502
Lint cleanup
rodrigo-barraza Dec 7, 2023
91e3ed5
Ignoring lint on NonSufficientFundsSearchModel
rodrigo-barraza Dec 7, 2023
f6c676f
Query fix
rodrigo-barraza Dec 8, 2023
456eb65
PDF fixes
rodrigo-barraza Dec 11, 2023
31aa3da
Merge branch 'main' into feature/17534
rodrigo-barraza Dec 11, 2023
6cc9bdc
Lint fixes
rodrigo-barraza Dec 11, 2023
be2d5e2
Cleanup
rodrigo-barraza Dec 11, 2023
e08b591
asdict
rodrigo-barraza Dec 11, 2023
5d24d0d
Model and service changes
rodrigo-barraza Dec 11, 2023
baa28b4
flake8 fix
rodrigo-barraza Dec 11, 2023
5063764
Version bump
rodrigo-barraza Dec 11, 2023
dff9b98
Test fixes
rodrigo-barraza Dec 11, 2023
08d62ac
New NSF query and cleanup for NSF PDF template
rodrigo-barraza Dec 11, 2023
13a4eb7
Feedback updates
rodrigo-barraza Dec 11, 2023
e2946ad
Moving logic to query, updating template
rodrigo-barraza Dec 12, 2023
a6fbaa4
NSF query refactoring
rodrigo-barraza Dec 12, 2023
f2cebe8
Unit test fixes
rodrigo-barraza Dec 13, 2023
848ec10
Merge branch 'main' into feature/17534
rodrigo-barraza Dec 13, 2023
fad3b66
Lint fix
rodrigo-barraza Dec 13, 2023
23c4099
Expanding unit test
rodrigo-barraza Dec 14, 2023
4aab599
Cleanup, new CFS method
rodrigo-barraza Dec 14, 2023
5d75820
Checking auth, getting latest CFS account
rodrigo-barraza Dec 15, 2023
9005132
Expanding tests
rodrigo-barraza Dec 15, 2023
521b252
Unit test fix
rodrigo-barraza Dec 15, 2023
b034d16
Flake8 fix
rodrigo-barraza Dec 15, 2023
ae9c1de
Expanding API test
rodrigo-barraza Dec 15, 2023
2b50ab4
Unit test fix
rodrigo-barraza Dec 15, 2023
89d80bc
Merge branch 'main' into feature/17534
rodrigo-barraza Jan 2, 2024
915cc0d
Updating NSF query
rodrigo-barraza Jan 12, 2024
19b8a90
Merge branch 'main' into feature/17534
rodrigo-barraza Jan 12, 2024
91785fa
revision update
rodrigo-barraza Jan 16, 2024
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: 49eaec3210e0
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 = '49eaec3210e0'
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),
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
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)
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
65 changes: 65 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,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.
"""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):
seeker25 marked this conversation as resolved.
Show resolved Hide resolved
"""Get non sufficient funds statement pdf."""
current_app.logger.info('<get_non_sufficient_funds_statement_pdf')
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')
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
159 changes: 159 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,159 @@
# 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 InvoiceSchema, 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(
seeker25 marked this conversation as resolved.
Show resolved Hide resolved
InvoiceModel, InvoiceReferenceModel)
.join(NonSufficientFundsModel, NonSufficientFundsModel.invoice_id == InvoiceModel.id)
.join(PaymentAccountModel, PaymentAccountModel.id == InvoiceModel.payment_account_id)
.join(PaymentLineItemModel, PaymentLineItemModel.invoice_id == InvoiceModel.id)
.join(InvoiceReferenceModel, InvoiceReferenceModel.invoice_id == InvoiceModel.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_schema = InvoiceSchema(exclude=('receipts', 'references', '_links'))
Copy link
Collaborator

@seeker25 seeker25 Dec 14, 2023

Choose a reason for hiding this comment

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

any reason why we can't use InvoiceSearchModel or a variation of it instead?

invoices = [result[0] for result in results]
seeker25 marked this conversation as resolved.
Show resolved Hide resolved
invoices_dump = invoice_schema.dump(invoices, many=True)

data = {
'total': total,
'invoices': invoices_dump,
'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_by_account_id(account_id)
Copy link
Collaborator

@seeker25 seeker25 Dec 14, 2023

Choose a reason for hiding this comment

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

this returns a list of cfs_accounts, not just one - should probably be looking for the one with the highest ID / newest perhaps?

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[0].cfs_account,
Copy link
Collaborator

@seeker25 seeker25 Dec 14, 2023

Choose a reason for hiding this comment

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

not ideal, you need to choose the correct one

'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.info('Invoice PDF Dict %s', invoice_pdf_dict)
Copy link
Collaborator

Choose a reason for hiding this comment

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

should this be debug? not info?


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')
2 changes: 1 addition & 1 deletion pay-api/src/pay_api/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@
Development release segment: .devN
"""

__version__ = '1.20.4' # pylint: disable=invalid-name
__version__ = '1.20.5' # pylint: disable=invalid-name
Loading
Loading