"""Collaborator related logic.""" from datetime import date from http import HTTPStatus from typing import Any, List, Optional from sqlalchemy.orm import QueryableAttribute from collaborator.connectors import mysql from collaborator.constants import error from collaborator.constants.features import BALANCE_CLEARING, SHOW_DP_ACTIVATION_CTA from collaborator.constants.header import ( COLLABORATOR_RESOURCE, LABEL_RESOURCE, PROFILE_TO_RESOURCE_MAP, ) from collaborator.constants.split_type import SplitTypeId from collaborator.constants.transaction import ( BALANCE_CLEARING_MSG, TYPE_BALANCE_CLEARING, ) from collaborator.models.ows import ows_payee from collaborator.models.rds.collaborator import Collaborator from collaborator.models.rds.collaborator_persister import CollaboratorPersister from collaborator.models.rds.split_persister import SplitPersister from collaborator.models.rds.statement_period_persister import StatementPeriodPersister from collaborator.models.rds.transaction_persister import TransactionPersister from collaborator.schemas.split import SplitSchema from collaborator.utils import api as api_utils from collaborator.utils import features, logging from collaborator.utils.error import OwsError from collaborator.utils.helpers import check_vendors_authorization, monetary_value from collaborator.utils.typing import Account, AuthorizedResources, User def create_collaborator( participant_id: Optional[str], name: str, account: Account, collaborator_type: Optional[str], currency: Optional[str], user: User, subaccount_id=None, description=None, internal_id=None, performance_rights=True, ) -> tuple: """Create a new collaborator. Args: participant_id (str): Participant ID for the collaborator. name (str): name of the collaborator. account (Account): Account which is creating the collaborator. currency (str): Three letter currency code. user (User): User who is creating the collaborator. subaccount_id (int): Optional subaccount for the collaborator. description (str): Collaborator description. internal_id (str): Collaborator internal identifier. performance_rights (bool): Collaborator performance rights. Returns: tuple: (collaborator dict, created bool) """ StatementPeriodPersister.get_or_create_open_statement_period(account.id) created_by = user.id data, created = CollaboratorPersister.create_collaborator( name, account, subaccount_id, participant_id, collaborator_type, currency, description, internal_id, performance_rights, created_by, ) logging.log_event( logging.LOG_EVENT_CREATE, "collaborator", data["id"], None, data, user ) return data, created def update_collaborator( collaborator_id: int, payload: dict, account: Account, user: User ) -> dict: """Update a collaborator. Args: collaborator_id (int): The collaborator's unique identifier payload (dict): Payload with the collaborator's update attributes. account (Account): Account which is updating the collaborator. user (User): User who is updating the collaborator. Returns: dict: the updated collaborator """ collaborator = get_by_id_and_account(collaborator_id, account) payload_dp_enabled = payload.pop("dp_enabled", False) is_enabling_dp = payload_dp_enabled and collaborator.get("dp_enabled_date") is None with mysql.create_session() as db_session: if is_enabling_dp: if not features.is_feature_enabled(SHOW_DP_ACTIVATION_CTA): raise OwsError.forbidden( message=error.ERROR_MESSAGE_FORBIDDEN_USER, code=error.ERROR_CODE_AUTHORIZATION, ) if features.is_feature_enabled(BALANCE_CLEARING): open_period = StatementPeriodPersister.get_open_statement_period( account.id ) balances = CollaboratorPersister.get_balances_for_ids([collaborator_id]) negated_balance = -balances[0].amount currency = collaborator["currency"] if negated_balance != 0: balance_clearing_result = TransactionPersister.create_transaction( session=db_session, collaborator_id=collaborator_id, transaction_type=TYPE_BALANCE_CLEARING, transaction_date=date.today(), original_amount=negated_balance, chargeable_amount=negated_balance, statement_period_id=open_period.statement_period_id, currency=currency, description=BALANCE_CLEARING_MSG, collaborator_share=None, transferwise_transaction_id=None, report_id=None, voided_transaction_id=None, ) if not balance_clearing_result: db_session.rollback() raise OwsError( code=error.ERROR_CODE_OWS_COLLABORATOR_PAYEE, message=error.ERROR_MESSAGE_BALANCE_CLEARING_FAILED, status=HTTPStatus.INTERNAL_SERVER_ERROR, ) ows_payee.create_collaborator_payee(collaborator_id) payload["updated_by"] = user.id updated_collaborator = CollaboratorPersister.update_collaborator( collaborator_id, payload, session=db_session, ) logging.log_event( logging.LOG_EVENT_UPDATE, "collaborator", updated_collaborator["id"], None, updated_collaborator, user, ) return updated_collaborator def get_by_id_and_account(collaborator_id, account): """Get a single collaborator based on its ID and the associated account. Args: collaborator_id (int): ID of the collaborator. account (Account): Account which created the collaborator. Returns: Response: a single collaborator """ return CollaboratorPersister.get_by_id_and_account(collaborator_id, account) def get_for_account(account, limit, offset, has_recipient=None): """Get collaborators based on an account. Args: account (Account): Account to get collaborators for. limit (int): how many collaborators to retrieve. offset (int): the offset (for pagination). Returns: list: list of collaborators """ return CollaboratorPersister.get_for_account(account, limit, offset, has_recipient) def get_collaborator_by_id(collaborator_id: int): """Get collaborator by ID. Args: collaborator_id (int): ID of the collaborator. Returns: dict: collaborator info """ return CollaboratorPersister.get_by_id(collaborator_id) def search_collaborators( authorized_resources: AuthorizedResources, vendor_id: Optional[int] = None, has_recipient: Optional[bool] = None, search_term: Optional[str] = None, collaborator_type: Optional[str] = None, dimensions: List[QueryableAttribute[Any]] = [Collaborator.name], offset: int = 0, limit: int = 0, exactly_match_term: Optional[bool] = False, ): """Search collaborators based on a set of filters. Args: authorized_resources (AuthorizedResources); list of resources this user is authorised to access. vendor_id (Optional[int]): vendor ID to filter by. has_recipient (Optional[bool]): Flag in order to filter the results search_term (Optional[str]): search term to filter by. offset (int): offset of the query cursor. limit (int): limit on the query result. exactly_match_term (bool): flag to search for exact match. Returns: dict: collaborator info """ if search_term == "": return api_utils.create_paginated_response([], 0) if vendor_id: vendor_ids = [vendor_id] elif authorized_resources.full_catalog_access: vendor_ids = None else: vendor_ids = [ res.id for res in authorized_resources if res.type == LABEL_RESOURCE ] # If profile resource type is "collaborator" (e.g. it's a Moneyhub or Documents profile) we # need to limit results to authorized collaborators. "Full catalog access" isn't supported # for those profile types, so we don't need to worry about that for now. if ( PROFILE_TO_RESOURCE_MAP[authorized_resources.profile_type] == COLLABORATOR_RESOURCE ): collaborator_ids = [ res.id for res in authorized_resources if res.type == COLLABORATOR_RESOURCE ] else: collaborator_ids = None return CollaboratorPersister.search( vendor_ids, collaborator_ids, search_term, dimensions=dimensions, has_recipient=has_recipient, collaborator_type=collaborator_type, offset=offset, limit=(None if limit == 0 else limit), exactly_match_term=exactly_match_term, ) def search_collaborators_by_name_or_id( authorized_resources: AuthorizedResources, vendor_id: Optional[int] = None, has_recipient: Optional[bool] = None, search_term: Optional[str] = None, offset: int = 0, limit: int = 0, ): """Search collaborators by name or ID based on a set of filters. See definition of search_collaborators for more info. """ return search_collaborators( authorized_resources=authorized_resources, vendor_id=vendor_id, has_recipient=has_recipient, search_term=search_term, dimensions=[Collaborator.name, Collaborator.collaborator_id], offset=offset, limit=limit, ) def _format_balance_result(result): if not result: return {"data": None} if result.transactions_currencies_count > 1 or ( result.transactions_currency and result.transactions_currency != result.currency ): return { "error": { "code": error.ERROR_CODE_PERIOD_CURRENCY_MISMATCH, "message": error.ERROR_MESSAGE_PERIOD_CURRENCY_MISMATCH, } } return {"data": monetary_value(result.currency, float(result.amount))} def get_balances_by_ids( authorized_resources: AuthorizedResources, collaborator_ids: List[int] ): """Get balances for multiple collaborators. Args: authorized_resources (AuthorizedResources): List of resources this user is authorised to access collaborator_ids (List[int]): List of IDs of collaborator to get balances for """ results = CollaboratorPersister.get_balances_for_ids(collaborator_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 ) results_by_id = { result.collaborator_id: result for result in results if result.vendor_id in authorized_vendor_ids } return [ _format_balance_result(results_by_id.get(collaborator_id)) for collaborator_id in collaborator_ids ] def get_splits_by_collaborator_id( collaborator_id: int, split_types: Optional[list[SplitTypeId]] = None, ) -> list[SplitSchema]: """Get a collaborator's splits, optionally filtered by split types.""" splits = SplitPersister.get_for_collaborator( collaborator_id=collaborator_id, split_types=split_types, ) return [SplitSchema.parse(split) for split in splits]