"""Statement Period related logic.""" from typing import List, Optional from collaborator.constants import error, features from collaborator.constants.header import MONEYHUB_PROFILE from collaborator.constants.statement_period import StatementPeriodStatus from collaborator.models.ows import ows_abacus_account, ows_account, ows_notifications from collaborator.models.rds.collaborator_persister import CollaboratorPersister from collaborator.models.rds.statement_period_persister import StatementPeriodPersister from collaborator.models.rds.transaction_persister import TransactionPersister from collaborator.utils import api as api_utils from collaborator.utils.error import OwsError from collaborator.utils.helpers import ( check_collaborators_authorization, check_vendors_authorization, get_from_response, monetary_value, sanitize_data, ) from collaborator.utils.typing import ACCOUNT_TYPE_VENDOR, Account def check_can_access_statement_period_data( authorized_resources, vendor_id=None, collaborator_id=None, statement_period_id=None ): """Check if this profile can modify statement period. Args: authorized_resources (AuthorizedResources): list of authorized resources for this profile. vendor_id (int): the vendor ID of the request (if collaborator_id is not defined). collaborator_id (int): the collaborator ID to filter by (if vendor_id is not defined). statement_period_id (int): the statement period ID to filter by. """ # If the request is attempting to access a collaborator's data _only_, then # we only need to check for collaborator-level (i.e. MoneyhubProfile) # resource access. Otherwise a request is being made for an entire vendor's # data, which requires vendor-level (i.e. CollaboratorsProfile) access. if collaborator_id: check_collaborators_authorization(authorized_resources, [collaborator_id]) collab = CollaboratorPersister.get_by_id(collaborator_id) vendor_id = collab["vendor_id"] else: if statement_period_id: period = StatementPeriodPersister.get_statement_period_by_id( statement_period_id ) if period is not None: vendor_id = period.vendor_id if not vendor_id: raise OwsError.forbidden( code=error.ERROR_CODE_AUTHORIZATION, message=error.ERROR_MESSAGE_FORBIDDEN_USER, ) check_vendors_authorization(authorized_resources, [vendor_id]) return vendor_id def check_vendor_currency_mismatch(vendor_id, collaborator_id): """Check for currency mismatch for a vendor and a collaborator. Args: vendor_id (int): The vendor's unique identifier collaborator_id (int): Collaborator's unique identifier Returns: str with the 3 code letter currency """ account = Account(id=vendor_id, type=ACCOUNT_TYPE_VENDOR) abacus_account = ows_abacus_account.get_abacus_account_metadata(account) vendor_currency = get_from_response(abacus_account, "currency_code") if collaborator_id: collab = CollaboratorPersister.get_by_id(collaborator_id) if vendor_currency != collab["currency"]: raise OwsError( code=error.ERROR_CODE_PERIOD_CURRENCY_MISMATCH, message=error.ERROR_MESSAGE_PERIOD_CURRENCY_MISMATCH, ) return vendor_currency def close_statement_period(vendor_id: int, period_name: str): """Close the statement period with the given id. Args: vendor_id (int): Vendor ID in context. Returns: dict: the updated statement period. """ _, txn_count = TransactionPersister.get_transactions_for_open_period(vendor_id) if txn_count < 1: raise OwsError( code=error.ERROR_CODE_CANNOT_CLOSE_EMPTY_PERIOD, message=error.ERROR_MESSAGE_CANNOT_CLOSE_EMPTY_PERIOD, ) closed_period, new_open_period = StatementPeriodPersister.close_statement_period( vendor_id, period_name ) collab_ids = CollaboratorPersister.get_with_statement_activity(vendor_id) ows_notifications.trigger_closed_period_notifications( [ { "active_collaborator_ids": collab_ids, "statement_period": ( { "id": closed_period["id"], "name": closed_period["name"], "vendor_id": closed_period["vendor_id"], "created_date": closed_period["created_date"], "updated_date": closed_period["updated_date"], "status": closed_period["status"], } if closed_period else closed_period ), } ] ) return new_open_period def bulk_close_statement_periods(period_name: str, new_abacus_statement_period_id: int): """Bulk close statement periods for direct payments enabled vendors.""" dp_enabled_vendor_ids = ows_account.get_vendors_for_feature( features.DIRECT_PAYMENTS_FEATURE_CONTROL ) collab_ids_by_vendor_id = CollaboratorPersister.bulk_get_with_statement_activity( dp_enabled_vendor_ids ) closed_periods_by_vendor_id = StatementPeriodPersister.bulk_close_statement_periods( period_name, dp_enabled_vendor_ids, new_abacus_statement_period_id ) notifications = [] for vendor_id in dp_enabled_vendor_ids: closed_period = closed_periods_by_vendor_id.get(vendor_id) collab_ids = collab_ids_by_vendor_id.get(vendor_id) notifications.append( { "active_collaborator_ids": collab_ids, "statement_period": ( { "id": closed_period["id"], "name": closed_period["name"], "vendor_id": closed_period["vendor_id"], "created_date": closed_period["created_date"], "updated_date": closed_period["updated_date"], "status": closed_period["status"], } if closed_period else closed_period ), } ) ows_notifications.trigger_closed_period_notifications(notifications) return None def get_transactions_for_statement_period( statement_period_id: int, limit: int = 0, offset: int = 0 ): """Get transactions for a statement period. Args: statement_period_id (int): statement period unique identifier statement's period transactions limit (int): how many transactions to retrieve. offset (int): the offset (for pagination). """ ( transactions, total_records, ) = TransactionPersister.get_transactions_for_statement_period( statement_period_id, limit, offset ) return api_utils.create_paginated_response(transactions, total_records) def format_statement_period_result(result): """Format statement period result.""" if not result: return {"data": None} return { "data": { "id": result.statement_period_id, "name": result.name, "status": result.status, "vendor_id": result.vendor_id, "created_date": sanitize_data(result.created_date), "updated_date": sanitize_data(result.updated_date), } } def statement_periods_dataloader(statement_period_ids, authorized_resources): """Get vendor-level totals for statement periods dataloader endpoint. Args: statement_period_ids (List[int]): List of statement period IDs authorized_resources (List): List of resources the requestor has access to Returns: List[Dict]: List of result corresponding to statement period IDs """ results = StatementPeriodPersister.get_statement_periods_by_id(statement_period_ids) vendor_ids = list({result.vendor_id for result in results}) authorized_vendor_ids = check_vendors_authorization( authorized_resources, vendor_ids, throw_if_unauthorized=False, allow_access_via_collaborator=True, ) results_by_statement_period_id = { result.statement_period_id: result for result in results if result.vendor_id in authorized_vendor_ids } message = [ format_statement_period_result( results_by_statement_period_id.get(statement_period_id) ) for statement_period_id in statement_period_ids ] return message def format_vendor_totals_result(result, vendor_currencies_by_id): """Format response object for vendor statement period totals result. Args: result: Query result object vendor_currencies_by_id (Dict[int, str]): Vendor currencies by ID Returns: List[Dict]: List of response objects """ if not result: return {"data": None} vendor_currency = vendor_currencies_by_id[result.vendor_id] # If not all transactions match vendor currency return error if result.currencies_count > 1 or ( result.currency is not None and result.currency != vendor_currency ): return { "error": { "code": error.ERROR_CODE_PERIOD_CURRENCY_MISMATCH, "message": error.ERROR_MESSAGE_PERIOD_CURRENCY_MISMATCH, } } return { "data": { "statement_period_id": result.statement_period_id, "revenues_total": monetary_value( vendor_currency, float(result.revenues_total) ), "expenses_total": monetary_value( vendor_currency, float(result.expenses_total) ), "payments_total": monetary_value( vendor_currency, float(result.payments_total) ), } } def statement_period_vendor_totals_dataloader( statement_period_ids, authorized_resources ): """Get vendor-level totals for statement periods dataloader endpoint. Args: statement_period_ids (List[int]): List of statement period IDs authorized_resources (List): List of resources the requestor has access to Returns: List[Dict]: List of result corresponding to statement period IDs """ results = StatementPeriodPersister.get_vendor_totals_for_statement_periods( statement_period_ids ) vendor_ids = list({result.vendor_id for result in results}) authorized_vendor_ids = check_vendors_authorization( authorized_resources, vendor_ids, throw_if_unauthorized=False ) vendor_currencies_by_id = { vendor_id: check_vendor_currency_mismatch(vendor_id, None) for vendor_id in vendor_ids } results_by_statement_period_id = { result.statement_period_id: result for result in results if result.vendor_id in authorized_vendor_ids } message = [ format_vendor_totals_result( results_by_statement_period_id.get(statement_period_id), vendor_currencies_by_id, ) for statement_period_id in statement_period_ids ] return message def get_statement_period_participations( authorized_resources, profile_type: str, statement_period_id: Optional[int], collaborator_id: Optional[int], status: Optional[str], limit: Optional[int], offset: Optional[int], term: Optional[str], ): """Get statement period participations. Args: authorized_resources (List): List of resources the requestor has access to statement_period_id (Optional[int]): ID of statement period to fetch participations for. Must be given if collaborator_id is not. collaborator_id (Optional[int]): ID of collaborator to fetch participations for. Must be given if statement_period_id is not. status (Optional[str]): Status to filter particiations by. limit (Optional[int]): Number to limit result by. offset (Optional[int]): Number to offset results by. Returns: Response: Paginated response. """ if collaborator_id: check_collaborators_authorization(authorized_resources, [collaborator_id]) if profile_type == MONEYHUB_PROFILE: status = StatementPeriodStatus.CLOSED from_first_activity = True else: from_first_activity = False rows, total_results = StatementPeriodPersister.get_statement_period_participations( collaborator_id, statement_period_id, status, limit, offset, from_first_activity, term, ) if len(rows) == 0: return api_utils.create_paginated_response([], 0) vendor_id = rows[0].vendor_id if statement_period_id and not collaborator_id: check_vendors_authorization(authorized_resources, [vendor_id]) vendor_currency = check_vendor_currency_mismatch(vendor_id, None) if any( (row.currency and row.currency != vendor_currency) or row.currencies_count > 1 for row in rows ): raise OwsError( code=error.ERROR_CODE_PERIOD_CURRENCY_MISMATCH, message=error.ERROR_MESSAGE_PERIOD_CURRENCY_MISMATCH, ) participations = [ { "statement_period_id": row.statement_period_id, "collaborator_id": row.collaborator_id, "opening_balance": monetary_value( vendor_currency, float(row.opening_balance) ), "closing_balance": monetary_value( vendor_currency, float(row.closing_balance) ), "revenues_total": monetary_value( vendor_currency, float(row.revenues_total) ), "expenses_total": monetary_value( vendor_currency, float(row.expenses_total) ), "payments_total": monetary_value( vendor_currency, float(row.payments_total) ), } for row in rows ] return api_utils.create_paginated_response(participations, total_results) def get_statement_periods( vendor_id: int, statuses: List[str], limit: int, offset: int, sort_key: Optional[str], sort_direction: Optional[str], term: Optional[str], ): """Get statement periods for vendor. Args: vendor_id (int): ID of vendor to return statement periods for. statuses (List[str]): List of statuses to filter results by. If empty results with all statuses will be returned. limit (int): Results limit. offset (int): Results offset. sort_key (Optional[str]): Column by which to sort results. sort_direction (Optional[str]): Direction in which to sort results. term (Optional[str]): Search term. Returns: Response: Paginated response. """ statement_periods, total_results = StatementPeriodPersister.get_statement_periods( vendor_id, statuses, limit, offset, sort_key, sort_direction, term ) return api_utils.create_paginated_response(statement_periods, total_results)