"""TransferWise handlers.""" import base64 from datetime import datetime from typing import Optional from flask import Response, g, request from flask_pydantic import validate from oto import response, status from oto.adaptors.flask import flaskify from collaborator import config from collaborator.api import app from collaborator.constants import error, transferwise_api, transferwise_webhook_events from collaborator.logic import transferwise from collaborator.schemas import BaseSchema from collaborator.utils import crypto from collaborator.utils import handlers as utils from collaborator.utils.helpers import ( check_collaborators_authorization, check_vendors_authorization, ) from collaborator.utils.typing import ACCOUNT_TYPE_VENDOR, Account, User def _account_from_vendors_auth(authorized_resources): account_id = utils.get_vendor_id_from_request() check_vendors_authorization(authorized_resources, [account_id]) return Account(type=ACCOUNT_TYPE_VENDOR, id=int(account_id)) def _account_from_collabs_auth(authorized_resources, collaborator_id): collabs = check_collaborators_authorization(authorized_resources, [collaborator_id]) vendor_id = collabs[collaborator_id]["vendor_id"] return Account(type=ACCOUNT_TYPE_VENDOR, id=vendor_id) @app.route("/transferwise/account-requirements//", methods=["POST"]) def refresh_account_requirements(source, target): """Refresh account requirements. Args: source (str): represents the source currency. target (str): represents the targeted currency. Returns: flask.Response """ payload = request.get_json() or {} if not payload or not source or not target: return flaskify( response.create_error_response( status=status.BAD_REQUEST, code=error.ERROR_CODE_MISSING_PARAMS, message=error.ERROR_MESSAGE_MISSING_PARAMS, ) ) return flaskify(transferwise.refresh_account_requirements(source, target, payload)) @app.route( "/transferwise/account-requirements//", methods=["GET"], ) def get_account_requirements(source, target): """Retrieve TW Account requirements based on currency. Args: source (string): The source currency. source (string): The target currency. Returns: flask.Response: A JSON payload of TW account requirements """ result = transferwise.get_account_requirements(source, target) return flaskify(result) @app.route("/transferwise/field-validation/", methods=["GET"]) def get_field_validation(validator): """Retrieve TW Account requirements based on currency. Args: validator (str): This would be the specific path of the validation. field_name (str): the field name that we would add it as query param to the request to TransferWise. field_value (str): the value that would be added to the query param. Returns: flask.Response: A JSON payload with the field validation """ query_string_params = request.query_string.decode("utf8") result = transferwise.get_field_validation(validator, query_string_params) return flaskify(result) class CreateProfileBody(BaseSchema): """Body for POST /transferwise/profile. ``vendor_id`` is also sent and resolved for authorization via ``_account_from_vendors_auth`` (which reads it off the request body). """ profile_id: int code: str subaccount_id: Optional[int] = None @app.route("/transferwise/profile", methods=["POST"]) @validate() @utils.fetch_authorized_resources def create_transferwise_profile(authorized_resources, user, body: CreateProfileBody): """Create a TransferWise Profile. Args: user (User): User who made the request body (CreateProfileBody): Profile creation parameters Returns: flask.Response """ account = _account_from_vendors_auth(authorized_resources) return flaskify( transferwise.create_or_update_profile( account, body.subaccount_id, user, body.profile_id, body.code ) ) @app.route("/transferwise/profile", methods=["GET"]) @utils.fetch_authorized_resources def get_transferwise_profile_for_vendor(authorized_resources, user): """Get a TransferWise Profile for a vendor. Note that a separate route should be used for subaccounts Args: account (Account): Account which made the request Returns: flask.Response """ account = _account_from_vendors_auth(authorized_resources) return flaskify(transferwise.get_vendor_profile(account)) @app.route("/transferwise/authorization-url", methods=["GET"]) @utils.fetch_authorized_resources def get_authorization_redirect_url(authorized_resources, user): """Get authorization redirect url. Return: flask.Response """ account = _account_from_vendors_auth(authorized_resources) url = transferwise.get_authorization_redirect_url(account) return flaskify(response.Response({"url": url})) @app.route( "/transferwise/collaborator//recipient", methods=["POST"] ) @utils.fetch_authorized_resources def create_recipient(collaborator_id, authorized_resources, user: User): """Create a TW recipient. Args: collaborator_id (int): Collaborator unique identifier. account (Account): Account which made the request. user: user that creates a recipient Returns: flask.Response """ account = _account_from_collabs_auth(authorized_resources, collaborator_id) payload = request.get_json() or {} if not payload or not collaborator_id: return flaskify( response.create_error_response( code=error.ERROR_CODE_MISSING_PARAMS, message=error.ERROR_MESSAGE_MISSING_PARAMS, ) ) return transferwise.create_recipient(account, payload, collaborator_id, user) @app.route( "/transferwise/collaborator//recipient/", methods=["DELETE"], ) @utils.fetch_authorized_resources def delete_recipient(collaborator_id, recipient_id, authorized_resources, user: User): """Delete a TW recipient. Args: collaborator_id (int): Collaborator unique identifier. recipient_id (int): TW Recipient unique identifier account (Account): Account which made the request. user: user that creates a recipient Returns: flask.Response """ account = _account_from_collabs_auth(authorized_resources, collaborator_id) return transferwise.delete_recipient(account, collaborator_id, recipient_id, user) def _extract_and_verify_wise_signature(): """Extract and verify wise signature.""" if transferwise_api.WEBHOOK_SIGNATURE_HEADER not in request.headers: raise Exception("Missing TransferWise signature header") signature = base64.b64decode( request.headers[transferwise_api.WEBHOOK_SIGNATURE_HEADER] ) g.ows.log.warning( "WEBHOOK Signature: {}".format( request.headers[transferwise_api.WEBHOOK_SIGNATURE_HEADER] ) ) g.ows.log.warning("WEBHOOK Data: {}".format(request.data.decode())) g.ows.log.warning("WEBHOOK Headers: {}".format(dict(request.headers))) if not crypto.verify_signature( config.TRANSFERWISE_PUBLIC_KEY, signature, request.data ): raise Exception("Incorrect signature") @app.route("/transferwise/webhook", methods=["POST"]) def webhook() -> Response: """Receives webhook events from TransferWise. See the TransferWise API docs for more info: https://transferwise.github.io/api-docs-partners/#webhook-events-event-types Returns: flask.Response """ if config.ENVIRONMENT != config.DEV_ENVIRONMENT: _extract_and_verify_wise_signature() if transferwise_api.WEBHOOK_TEST_HEADER in request.headers: g.ows.log.warning("WEBHOOK Test header found") return flaskify(response.Response(status=status.OK)) payload = request.get_json() or {} if "data" not in payload or "event_type" not in payload: raise Exception(f"Missing expected TransferWise payload parameters: {payload}") data = payload["data"] event_type = payload["event_type"] if ( event_type == transferwise_webhook_events.application_subscriptions["profile_verification"] ): g.ows.log.warning( "WEBHOOK TW_PROFILE: {} {}".format( data["resource"]["id"], data["current_state"] ) ) return flaskify( transferwise.update_verification_status( data["resource"]["id"], data["current_state"] ) ) if ( event_type == transferwise_webhook_events.application_subscriptions["transfers_state"] ): g.ows.log.warning( "WEBHOOK TW_TRANSFER_STATE: {} {}".format( data["resource"]["id"], data["current_state"] ) ) occurred_at = datetime.fromisoformat(data["occurred_at"]) return flaskify( transferwise.update_payment_status( data["resource"]["id"], data["current_state"], occurred_at ) ) raise Exception(f"Unhandled TransferWise webhook event: {payload}") class SubscribeWebhooksBody(BaseSchema): """Body for POST /transferwise/application-webhooks-subscription.""" name: str trigger_on: str @app.route("/transferwise/application-webhooks-subscription", methods=["POST"]) @validate() def subscribe_application_to_webhooks(body: SubscribeWebhooksBody): """Subscribe application to webhooks. Returns: flask.Response """ return flaskify( transferwise.subscribe_application_to_event(body.name, body.trigger_on) ) @app.route("/transferwise/quote", methods=["POST"]) @utils.require_body_params(["quotes"]) @utils.fetch_authorized_resources def create_quote(params: dict, authorized_resources, user): """Get a quote for a given source and target. Args: account (Account): Account which made the request. Returns: flask.Response """ account = _account_from_vendors_auth(authorized_resources) subaccount_id = params.get("subaccount", None) params.pop("subaccount_id", None) return flaskify(transferwise.create_quote(account, params, subaccount_id)) @app.route("/transferwise/batch-payment", methods=["POST"]) @utils.require_body_params(["profile_id", "sourceCurrency", "name"]) @utils.fetch_authorized_resources def create_batch_payment(params: dict, authorized_resources, user: User): """Create a transfer for a recipient. Args: account (Account): Account which made the request. Returns: flask.Response """ account = _account_from_vendors_auth(authorized_resources) subaccount_id = params.pop("subaccount_id", None) return flaskify( transferwise.create_batch_payment(account, user, params, subaccount_id) ) @app.route( "/transferwise/profile//batch-group/", methods=["GET"], ) @utils.fetch_authorized_resources def get_batch_group( profile_id: int, batch_group_id: str, authorized_resources, user: User ) -> response.Response: """Get batch transfer group details by id. Args: account (Account): Account which made the request. profile_id (int): TransferWise profile id batch_group_id (str): UUID for the transfer batch group Returns: flask.Response """ account_id = transferwise.check_can_access_batch_group( authorized_resources, batch_group_id ) account = Account(type=ACCOUNT_TYPE_VENDOR, id=account_id) return flaskify(transferwise.get_batch_group(account, profile_id, batch_group_id)) @app.route( "/transferwise/simulate/transfers//", methods=["POST"], ) def simulate_transfer_processing(transfer_id: int, transfer_status: str) -> Response: """Simulate transfer processing. Args: account (Account): Account which made the request. transfer_id (int): TransferWise transfer id transfer_status (int): TransferWise transfer status Returns: flask.Response """ if config.ENVIRONMENT == config.PROD_ENVIRONMENT: return flaskify( response.create_error_response( status=status.FORBIDDEN, code="forbidden", message="Forbidden" ) ) return flaskify( transferwise.simulate_transfer_processing(transfer_id, transfer_status) ) @app.route("/transferwise/batch/", methods=["GET"]) @utils.fetch_authorized_resources def get_batch(batch_id: int, authorized_resources, user) -> Response: """Get a TransferWise batch by its ID. Args: batch_id (int): ID of the batch to get. account (Account): Account which is making the request. Returns: flask.Response """ account_id = transferwise.check_can_access_batch(authorized_resources, batch_id) return flaskify(transferwise.get_batch(int(account_id), batch_id)) @app.route("/transferwise/batches", methods=["GET"]) @utils.fetch_authorized_resources def get_batches(authorized_resources, user) -> Response: """Get all TransferWise batches by account. Args: account (Account): Account which is making the request. Returns: flask.Response """ account = _account_from_vendors_auth(authorized_resources) return flaskify(transferwise.get_batches(account)) @app.route("/transferwise/batch//transactions", methods=["GET"]) @utils.fetch_authorized_resources def get_transactions_for_batch(batch_id: int, authorized_resources, user) -> Response: """Get TransferWise transactions based on a batch ID. Args: batch_id (int): ID of the batch to get transactions for. account (Account): Account which is making the request. Returns: flask.Response """ account_id = transferwise.check_can_access_batch(authorized_resources, batch_id) account = Account(type=ACCOUNT_TYPE_VENDOR, id=account_id) return flaskify(transferwise.get_transactions_for_batch(batch_id, account)) class CancelBatchPaymentBody(BaseSchema): """Body for PUT /transferwise/batch//cancel.""" subaccount_id: Optional[int] = None @app.route("/transferwise/batch//cancel", methods=["PUT"]) @validate() @utils.fetch_authorized_resources def cancel_batch_payment( authorized_resources, user, batch_id: int, body: CancelBatchPaymentBody ) -> Response: """Cancel batch payment. Args: batch_id (int): ID of the batch to get transactions for. body (CancelBatchPaymentBody): Optional subaccount selector. Returns: flask.Response """ account_id = transferwise.check_can_access_batch(authorized_resources, batch_id) account = Account(type=ACCOUNT_TYPE_VENDOR, id=account_id) return flaskify( transferwise.cancel_batch_payment(account, user, batch_id, body.subaccount_id) ) class TransferRequirementEntry(BaseSchema): """A single currency/transfer pair in the transfer-requirements body. ``transfer`` is forwarded to the TransferWise API as-is, so it stays an untyped dict. """ currency: str transfer: dict class TransferRequirementsBody(BaseSchema): """Body for POST /transferwise/transfer-requirements.""" transfers: list[TransferRequirementEntry] @app.route("/transferwise/transfer-requirements", methods=["POST"]) @validate() def get_transfer_requirements(body: TransferRequirementsBody) -> Response: """Get transfer requirements. Returns: flask.Response """ transfers = [entry.model_dump() for entry in body.transfers] return flaskify(transferwise.get_transfer_requirements(transfers))