Example #1
0
 def process_account_creation_request(self, user_input):
     try:
         attach_transfer_account_to_user(self.user)
         disbursement_amount = cents_to_dollars(
             config.SELF_SERVICE_WALLET_INITIAL_DISBURSEMENT)
         self.send_sms(self.user.phone,
                       "account_creation_success_sms",
                       disbursement_amount=disbursement_amount,
                       token_name=default_token(self.user).name)
     except Exception as e:
         self.send_sms(self.user.phone, "account_creation_error_sms")
         raise Exception('Account creation failed. Error: ', e)
Example #2
0
    def send_token(sender: User, recipient: User, amount: float,
                   reason_str: str, reason_id: int):
        try:
            exchanged_amount = TokenProcessor.transfer_token(
                sender, recipient, amount, reason_id)

            sender_tx_time = pendulum.now(sender.default_organisation.timezone)
            recipient_tx_time = pendulum.now(
                recipient.default_organisation.timezone)

            sender_balance = TokenProcessor.get_balance(sender)
            recipient_balance = TokenProcessor.get_balance(recipient)
            if exchanged_amount is None:
                TokenProcessor.send_success_sms("send_token_sender_sms",
                                                sender, recipient, amount,
                                                reason_str, sender_tx_time,
                                                sender_balance)

                TokenProcessor.send_success_sms("send_token_recipient_sms",
                                                recipient, sender, amount,
                                                reason_str, recipient_tx_time,
                                                recipient_balance)
            else:
                TokenProcessor.exchange_success_sms(
                    "exchange_token_sender_sms", sender, recipient, amount,
                    exchanged_amount, sender_tx_time, sender_balance)

                TokenProcessor.exchange_success_sms("exchange_token_agent_sms",
                                                    recipient, sender,
                                                    exchanged_amount, amount,
                                                    recipient_tx_time,
                                                    recipient_balance)

        except InsufficientBalanceError as e:
            token_balances_dollars, token_exchanges = TokenProcessor._get_token_balances(
                sender)

            TokenProcessor.send_sms(sender,
                                    "insufficient_balance_sms",
                                    amount=cents_to_dollars(amount),
                                    token_name=default_token(sender).name,
                                    recipient=recipient.user_details(),
                                    token_balances=token_balances_dollars)

        except TransferAmountLimitError as e:
            # Use a different message here from that used for exchange,
            # because the issue is caused by KYC (ie something the user can change by providing), rather than
            # our price-stability controls
            TokenProcessor.send_sms(sender,
                                    "transfer_amount_error_sms",
                                    amount=rounded_dollars(
                                        e.transfer_amount_limit),
                                    token=e.token,
                                    limit_period=e.limit_time_period_days)

        except Exception as e:
            # TODO: SLAP? all the others take input in cents
            TokenProcessor.send_sms(sender,
                                    "send_token_error_sms",
                                    amount=cents_to_dollars(amount),
                                    token_name=default_token(sender).name,
                                    recipient=recipient.user_details())
Example #3
0
    def custom_display_text(menu: UssdMenu, ussd_session: UssdSession) -> str:
        """
        Many USSD responses include user-specific data that is stored inside the USSD session. This function
        extracts the appropriate session data based on the current menu name and then inserts them as keywords in the
        i18n function.
        :param menu: The USSD menu to create a text response for
        :param ussd_session: The ussd session containing user data
        :return: raw ussd menu text string
        """

        user = ussd_session.user

        if menu.name == 'about_my_business':
            bio = next(
                filter(lambda x: x.name == 'bio', user.custom_attributes),
                None)
            if bio:
                bio_text = bio.value.strip('"')
            else:
                bio_text = None

            if bio_text is None or '':
                return i18n_for(user, "{}.none".format(menu.display_key))
            else:
                return i18n_for(user,
                                "{}.bio".format(menu.display_key),
                                user_bio=bio_text)

        if menu.name == 'send_token_confirmation':
            recipient = get_user_by_phone(
                ussd_session.get_data('recipient_phone'), should_raise=True)
            recipient_phone = recipient.user_details()
            token = default_token(user)
            transaction_amount = ussd_session.get_data('transaction_amount')
            transaction_reason = ussd_session.get_data(
                'transaction_reason_i18n')
            return i18n_for(
                user,
                menu.display_key,
                recipient_phone=recipient_phone,
                token_name=token.symbol,
                transaction_amount=cents_to_dollars(transaction_amount),
                transaction_reason=transaction_reason)

        if menu.name == 'exchange_token_confirmation':
            agent = get_user_by_phone(ussd_session.get_data('agent_phone'),
                                      should_raise=True)
            agent_phone = agent.user_details()
            token = default_token(user)
            exchange_amount = ussd_session.get_data('exchange_amount')
            return i18n_for(user,
                            menu.display_key,
                            agent_phone=agent_phone,
                            token_name=token.symbol,
                            exchange_amount=cents_to_dollars(exchange_amount))

        # in matching is scary since it might pick up unintentional ones
        if 'exit' in menu.name or 'help' == menu.name:
            return i18n_for(user,
                            menu.display_key,
                            support_phone='+254757628885')

        # in matching is scary since it might pick up unintentional ones
        if 'pin_authorization' in menu.name or 'current_pin' == menu.name:
            if user.failed_pin_attempts is not None and user.failed_pin_attempts > 0:
                return i18n_for(user,
                                "{}.retry".format(menu.display_key),
                                remaining_attempts=3 -
                                user.failed_pin_attempts)
            else:
                return i18n_for(user, "{}.first".format(menu.display_key))

        if menu.name == 'directory_listing' or menu.name == 'send_token_reason':

            blank_template = i18n_for(user, menu.display_key, options='')

            blank_len = len(blank_template)

            most_relevant_usages = ussd_session.get_data(
                'transfer_usage_mapping')

            options = UssdProcessor.fit_usages(ussd_session,
                                               most_relevant_usages, blank_len,
                                               user, 0, [0])

            # current_usages = most_relevant_usages[:ITEMS_PER_MENU]
            return i18n_for(user, menu.display_key, options=options)

        if menu.name == 'directory_listing_other' or menu.name == 'send_token_reason_other':

            most_relevant_usages = ussd_session.get_data(
                'transfer_usage_mapping')
            usage_menu_nr = ussd_session.get_data('usage_menu')
            usage_stack = ussd_session.get_data('usage_index_stack') or [0]

            start_of_list = usage_stack[usage_menu_nr]

            total_usages = len(most_relevant_usages)

            # First see if we can fit remaining usages onto the one page
            if start_of_list + ITEMS_PER_MENU > total_usages:
                part = 'first' if start_of_list == 0 else 'last'
                current_usages = most_relevant_usages[
                    start_of_list:total_usages]
                menu_options = UssdProcessor.create_usages_list(
                    current_usages, user)

                translated_menu = i18n_for(user,
                                           "{}.{}".format(
                                               menu.display_key, part),
                                           other_options=menu_options)

                if len(translated_menu) <= USSD_MAX_LENGTH:
                    return translated_menu

            # Oh well, guess we just have to fit as many as possible then

            part = 'first' if start_of_list == 0 else 'middle'

            blank_template = i18n_for(user,
                                      "{}.{}".format(menu.display_key, part),
                                      other_options='')

            blank_len = len(blank_template)

            options = UssdProcessor.fit_usages(ussd_session,
                                               most_relevant_usages, blank_len,
                                               user, start_of_list,
                                               usage_stack)

            # current_usages = most_relevant_usages[:ITEMS_PER_MENU]
            return i18n_for(user,
                            "{}.{}".format(menu.display_key, part),
                            other_options=options)

        return i18n_for(user, menu.display_key)
    def post(self):
        # Process post data
        post_data = request.get_json()
        account_ids = []
        relist_existing = True
        if post_data:
            account_ids = post_data.get('accounts', [])
            relist_existing = post_data.get('relist_existing', True)

        payout_withdrawal_limit = g.active_organisation._minimum_vendor_payout_withdrawal_wei or 0

        if not isinstance(account_ids, list):

            response_object = {
                'message': 'Accounts parameter expects a list',
            }
            return make_response(jsonify(response_object)), 400

        if account_ids:
            vendors = db.session.query(TransferAccount)\
                .filter(TransferAccount.account_type == TransferAccountType.USER)\
                .filter(TransferAccount.id.in_(account_ids))\
                .all()

            for vendor in vendors:
                if not vendor.primary_user.has_vendor_role:

                    response_object = {
                        'message':
                        f'Transfer account with id {vendor.id} not a vendor account. Please only IDs of vendor accounts',
                    }
                    return make_response(jsonify(response_object)), 400

            selected_vendor_ids = [v.id for v in vendors]
            list_difference = [
                item for item in account_ids if item not in selected_vendor_ids
            ]
            if list_difference:
                response_object = {
                    'message':
                    f'Accounts {list_difference} were requested but do not exist',
                }
                return make_response(jsonify(response_object)), 400
        else:
            vendor_users = db.session.query(User)\
                .filter(User.has_vendor_role)\
                .all()

            vendors = [v.default_transfer_account for v in vendor_users]
            vendors = filter(lambda vendor: not vendor.is_ghost, vendors)

        output = io.StringIO()
        writer = csv.writer(output)

        writer.writerow([
            'Vendor Account ID',
            'Phone',
            'ContactName',
            'Current Balance',
            'Total Sent',
            'Total Received',
            'Approved',
            'Beneficiary',
            'Vendor',
            'InvoiceDate',
            'DueDate',
            'Transfer ID',
            'UnitAmount',
            'Payment Has Been Made',
            'Bank Payment Date',
        ])
        for v in vendors:
            if relist_existing:
                withdrawals = (CreditTransfer.query.filter(
                    CreditTransfer.sender_transfer_account_id == v.id).filter(
                        CreditTransfer.transfer_status ==
                        TransferStatusEnum.PENDING).all())
            else:
                withdrawals = []

            withdrawal_amount = Decimal(v._balance_wei or 0) / Decimal(1e16)
            if withdrawal_amount > 0 and (v._balance_wei
                                          or 0) >= payout_withdrawal_limit:
                transfer = make_withdrawal_transfer(
                    withdrawal_amount,
                    token=v.token,
                    send_user=v.primary_user,
                    sender_transfer_account=v,
                    transfer_mode=TransferModeEnum.INTERNAL,
                    require_sender_approved=False,
                    automatically_resolve_complete=False,
                )

                db.session.flush()

                withdrawals.append(transfer)

            for w in withdrawals:
                writer.writerow([
                    v.id,
                    v.primary_user.phone,
                    f'{v.primary_user.first_name or ""} {v.primary_user.last_name or ""}',
                    cents_to_dollars(v.balance),
                    cents_to_dollars(v.total_sent),
                    cents_to_dollars(v.total_received),
                    v.is_approved,
                    v.primary_user.has_beneficiary_role,
                    v.primary_user.has_vendor_role,
                    datetime.today().strftime('%Y-%m-%d'),
                    (datetime.today() +
                     timedelta(days=7)).strftime('%Y-%m-%d'),
                    w.id,
                    cents_to_dollars(w.transfer_amount),
                    '',
                    '',
                ])

        # Encode the CSV such that it can be sent as a file
        bytes_output = io.BytesIO()
        bytes_output.write(output.getvalue().encode('utf-8'))
        bytes_output.seek(0)
        return send_file(bytes_output,
                         as_attachment=True,
                         attachment_filename='vendor_payout.csv',
                         mimetype='text/csv')
    def post(self):
        # Handle a file upload, or CSV in JSON
        if request.files:
            flask_file = request.files['file']
            stream = codecs.iterdecode(flask_file.stream, 'utf-8')
            reader = csv.DictReader(stream)
        else:
            post_data = request.get_json()
            if not post_data:
                response_object = {'message': 'Please provide a CSV file'}
                return make_response(jsonify(response_object)), 400

            csv_data = post_data.get('csv_data', [])
            f = io.StringIO(csv_data)
            reader = csv.DictReader(f)

        transfers = []
        for line in reader:
            tid = line['Transfer ID']
            transfer = db.session.query(CreditTransfer).filter(
                CreditTransfer.id == tid).first()
            message = ''

            if not transfer:
                message = f'Transfer with ID {tid} not found!'
                transfers.append((tid, None, message))
                continue

            if transfer.transfer_type != TransferTypeEnum.WITHDRAWAL:
                message = f'Not a withdrawal!'
                transfers.append((tid, None, message))
                continue

            got_amount = round(dollars_to_cents(line["UnitAmount"]))
            expected_amount = round(transfer.transfer_amount)
            if got_amount != expected_amount:
                message = f'Transfer Amounts do not match (got {cents_to_dollars(got_amount)}, expected {cents_to_dollars(expected_amount)})!'
                transfers.append((tid, None, message))
                continue

            try:
                if line['Payment Has Been Made'].upper(
                ) == 'TRUE' and line['Bank Payment Date']:
                    transfer.resolve_as_complete_and_trigger_blockchain()
                    message = 'Transfer Success'
                elif line['Payment Has Been Made'] == 'FALSE':
                    transfer.resolve_as_rejected()
                    message = 'Transfer Rejected'
            except Exception as e:
                message = str(e)

            transfers.append((tid, transfer, message))

        output = io.StringIO()
        writer = csv.writer(output)

        writer.writerow([
            'Transfer ID', 'Vendor Account ID', 'Phone', 'First Name',
            'Last Name', 'Transfer Created', 'Transfer Type',
            'Transfer Amount', 'Transfer Status', 'Message'
        ])
        for tid, t, m in transfers:
            writer.writerow([
                tid, t and t.sender_transfer_account.id, t
                and t.sender_transfer_account.primary_user.phone, t
                and t.sender_transfer_account.primary_user.first_name, t
                and t.sender_transfer_account.primary_user.last_name, t
                and t.created, t and t.transfer_type.value, t
                and cents_to_dollars(t.transfer_amount), t
                and t.transfer_status.value, m
            ])
        bytes_output = io.BytesIO()
        bytes_output.write(output.getvalue().encode('utf-8'))
        bytes_output.seek(0)
        return send_file(bytes_output,
                         as_attachment=True,
                         attachment_filename='vendor_payout.csv',
                         mimetype='text/csv')
Example #6
0
def test_cents_to_dollars():
    assert CreditTransferUtils.cents_to_dollars(100) == 1
    def custom_display_text(menu: UssdMenu, ussd_session: UssdSession) -> str:
        """
        Many USSD responses include user-specific data that is stored inside the USSD session. This function
        extracts the appropriate session data based on the current menu name and then inserts them as keywords in the
        i18n function.
        :param menu: The USSD menu to create a text response for
        :param ussd_session: The ussd session containing user data
        :return: raw ussd menu text string
        """

        user = ussd_session.user

        if menu.name == 'about_me':
            bio = next(filter(lambda x: x.name == 'bio', user.custom_attributes), None)
            first_name = user.first_name
            last_name = user.last_name
            gender = next(filter(lambda x: x.name == 'gender', user.custom_attributes), None)
            location = user.location

            if bio and gender:
                bio_text = bio.value.strip('"')
                gender_text = gender.value.strip('"')
            else:
                bio_text = None
                gender_text = None

            # translations
            absent_value_placeholder = "missing"
            if user.preferred_language == "sw":
                absent_value_placeholder = 'hakuna'
                if gender_text == 'male':
                    gender_text = 'mwanaume'
                elif gender_text == 'female':
                    gender_text = 'mwanamke'

            if first_name == 'Unknown first name':
                first_name = None

            if last_name == 'Unknown last name':
                last_name = None

            if bio_text == 'Unknown business':
                bio_text = None

            if gender_text == 'Unknown gender':
                gender_text = None

            if location == 'Unknown location':
                location = None

            # define final values to show in menu
            first_name = first_name or absent_value_placeholder
            last_name = last_name or absent_value_placeholder
            bio_text = bio_text or absent_value_placeholder
            gender_text = gender_text or absent_value_placeholder
            location = location or absent_value_placeholder

            full_name = "{} {}".format(first_name, last_name)
            if first_name == absent_value_placeholder and last_name == absent_value_placeholder:
                full_name = absent_value_placeholder

            return i18n_for(user, "{}.profile".format(menu.display_key),
                            full_name=full_name,
                            gender=gender_text,
                            location=location, user_bio=bio_text)

        if menu.name == 'send_token_pin_authorization':
            recipient = get_user_by_phone(ussd_session.get_data('recipient_phone'), 'KE', True)
            other_user_details = recipient.user_details()
            user_details = user.user_details()
            token = default_token(user)
            transaction_amount = ussd_session.get_data('transaction_amount')
            if user.failed_pin_attempts > 0:
                return i18n_for(
                    user=user,
                    key="{}.{}".format(menu.display_key, 'retry'),
                    remaining_attempts=3 - user.failed_pin_attempts
                )
            else:
                return i18n_for(
                    user=user,
                    key="{}.{}".format(menu.display_key, 'first'),
                    transaction_amount=cents_to_dollars(transaction_amount),
                    token_name=token.symbol,
                    other_user_details=other_user_details,
                    user_details=user_details

                )

        if menu.name == 'exit_successful_send_token':
            recipient = get_user_by_phone(ussd_session.get_data('recipient_phone'), 'KE', True)
            other_user_details = recipient.user_details()
            user_details = user.user_details()
            token = default_token(user)
            transaction_amount = ussd_session.get_data('transaction_amount')
            return i18n_for(
                user=user,
                key="{}".format(menu.display_key, 'exit_successful_send_token'),
                transaction_amount=cents_to_dollars(transaction_amount),
                token_name=token.symbol,
                other_user_details=other_user_details,
                user_details=user_details

            )

        if menu.name == 'exchange_token_confirmation':
            agent = get_user_by_phone(ussd_session.get_data('agent_phone'), 'KE', True)
            agent_phone = agent.user_details()
            token = default_token(user)
            exchange_amount = ussd_session.get_data('exchange_amount')
            return i18n_for(
                user, menu.display_key,
                agent_phone=agent_phone,
                token_name=token.symbol,
                exchange_amount=cents_to_dollars(exchange_amount)
            )

        # in matching is scary since it might pick up unintentional ones
        if 'exit' in menu.name or 'help' == menu.name:
            return i18n_for(
                user, menu.display_key,
                support_phone='+254757628885'
            )

        # in matching is scary since it might pick up unintentional ones
        if 'pin_authorization' in menu.name or 'current_pin' == menu.name:
            if user.failed_pin_attempts is not None and user.failed_pin_attempts > 0:
                return i18n_for(
                    user, "{}.retry".format(menu.display_key),
                    remaining_attempts=3 - user.failed_pin_attempts
                )
            else:
                return i18n_for(user, "{}.first".format(menu.display_key))

        if menu.name == 'directory_listing' or menu.name == 'send_token_reason':

            blank_template = i18n_for(
                user, menu.display_key, options=''
            )

            blank_len = len(blank_template)

            most_relevant_usages = ussd_session.get_data('transfer_usage_mapping')

            options = KenyaUssdProcessor.fit_usages(
                ussd_session,
                most_relevant_usages,
                blank_len,
                user,
                0,
                [0]
            )

            # current_usages = most_relevant_usages[:ITEMS_PER_MENU]
            return i18n_for(
                user, menu.display_key,
                options=options
            )

        if menu.name == 'directory_listing_other' or menu.name == 'send_token_reason_other':

            most_relevant_usages = ussd_session.get_data('transfer_usage_mapping')
            usage_menu_nr = ussd_session.get_data('usage_menu')
            usage_stack = ussd_session.get_data('usage_index_stack') or [0]

            start_of_list = usage_stack[usage_menu_nr]

            total_usages = len(most_relevant_usages)

            # First see if we can fit remaining usages onto the one page
            if start_of_list + ITEMS_PER_MENU > total_usages:
                part = 'first' if start_of_list == 0 else 'last'
                current_usages = most_relevant_usages[start_of_list:total_usages]
                menu_options = KenyaUssdProcessor.create_usages_list(current_usages, user)

                translated_menu = i18n_for(
                    user, "{}.{}".format(menu.display_key, part),
                    other_options=menu_options
                )

                if len(translated_menu) <= USSD_MAX_LENGTH:
                    return translated_menu

            # Oh well, guess we just have to fit as many as possible then

            part = 'first' if start_of_list == 0 else 'middle'

            blank_template = i18n_for(
                user, "{}.{}".format(menu.display_key, part),
                other_options=''
            )

            blank_len = len(blank_template)

            options = KenyaUssdProcessor.fit_usages(
                ussd_session,
                most_relevant_usages,
                blank_len,
                user,
                start_of_list,
                usage_stack)

            # current_usages = most_relevant_usages[:ITEMS_PER_MENU]
            return i18n_for(
                user, "{}.{}".format(menu.display_key, part),
                other_options=options
            )

        return i18n_for(user, menu.display_key)