-
Notifications
You must be signed in to change notification settings - Fork 40
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
Changes from all commits
6addff1
7ff9e8f
39b96f4
4ea8cf0
5f6a858
6615d98
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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), | ||
sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], ), | ||
sa.PrimaryKeyConstraint('id') | ||
) | ||
|
||
|
||
def downgrade(): | ||
op.drop_table('non_sufficient_funds') |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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.""" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.""" | ||
|
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) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
||
|
@@ -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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) |
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') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() |
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') |
There was a problem hiding this comment.
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 systemThere was a problem hiding this comment.
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 bothinvoice_number
andinvoice_id
should be fine, even though it's might seem a little redundant.