├── requirements.txt ├── attbillsplitter ├── __init__.py ├── tests.py ├── errors.py ├── entrypoints.py ├── models.py ├── utils.py ├── services.py └── main.py ├── setup.cfg ├── .gitignore ├── LICENSE ├── setup.py └── README.rst /requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /attbillsplitter/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 2 | *.swp 3 | *.pyc 4 | 5 | # 6 | *.db 7 | *.log 8 | chromedriver 9 | 10 | # 11 | venv/ 12 | 13 | # 14 | build/ 15 | dist/ 16 | *.egg-info 17 | *.eggs 18 | .cache/ 19 | 20 | -------------------------------------------------------------------------------- /attbillsplitter/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | """Test cases for att-bill-splitter.""" 3 | 4 | import datetime as dt 5 | from attbillsplitter.main import get_start_end_date 6 | 7 | 8 | def test_get_start_end_date(): 9 | billing_cycle_name = 'Mar 15 - Apr 14, 2016' 10 | start_date = dt.date(2016, 3, 15) 11 | end_date = dt.date(2016, 4, 14) 12 | assert get_start_end_date(billing_cycle_name) == (start_date, end_date) 13 | -------------------------------------------------------------------------------- /attbillsplitter/errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | """Exceptions used in main module.""" 3 | 4 | from peewee import IntegrityError 5 | 6 | 7 | class BaseError(Exception): 8 | pass 9 | 10 | 11 | class ConfigError(BaseError): 12 | pass 13 | 14 | 15 | class UrlError(BaseError): 16 | pass 17 | 18 | 19 | class LoginError(BaseError): 20 | pass 21 | 22 | 23 | class ParsingError(BaseError): 24 | pass 25 | 26 | 27 | class CalculationError(BaseError): 28 | pass 29 | 30 | 31 | __all__ = ['ConfigError', 'UrlError', 'LoginError', 'ParsingError', 32 | 'CalculationError', 'IntegrityError'] 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /attbillsplitter/entrypoints.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | """Entrypoints for att-bill-splitter: 3 | * split-bill 4 | """ 5 | 6 | 7 | def split_bill(): 8 | """Parse AT&T bill and split wireless charges among users.""" 9 | from attbillsplitter.main import run_split_bill 10 | run_split_bill() 11 | 12 | 13 | def print_summary(): 14 | """Print wireless monthly summary among users.""" 15 | from attbillsplitter.services import run_print_summary 16 | run_print_summary() 17 | 18 | 19 | def print_details(): 20 | """Print wireless monthly details among users.""" 21 | from attbillsplitter.services import run_print_details 22 | run_print_details() 23 | 24 | 25 | def notify_users(): 26 | """Print wireless monthly details among users.""" 27 | from attbillsplitter.services import run_notify_users 28 | run_notify_users() 29 | 30 | 31 | def add_onetime_fee(): 32 | """Add one time fee to all users in most recent billing cycle""" 33 | from attbillsplitter.services import add_onetime_fee 34 | add_onetime_fee() 35 | 36 | 37 | def init_twilio(): 38 | """Initialize twilio credentials.""" 39 | from attbillsplitter.utils import initialize_twiolio 40 | initialize_twiolio() 41 | 42 | 43 | def init_payment_msg(): 44 | """Initialize twilio credentials.""" 45 | from attbillsplitter.utils import initialize_payment_msg 46 | initialize_payment_msg() 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | """Setup for att-bill-splitter.""" 3 | 4 | from setuptools import find_packages, setup 5 | 6 | setup( 7 | name='att-bill-splitter', 8 | # packages=['att-bill-splitter'], 9 | version='0.4.8', 10 | description='Parse AT&T bill and split wireless charges among users.', 11 | author='Brian Zhang', 12 | author_email='leapingzhang@gmail.com', 13 | url='https://github.com/brianzq/att-bill-splitter', 14 | # download_url='' 15 | install_requires=[ 16 | 'beautifulsoup4==4.5.1', 17 | 'click==6.6', 18 | 'future==0.16.0', 19 | 'peewee==2.8.4', 20 | 'python-slugify==1.2.1', 21 | 'requests', 22 | 'twilio==5.6.0', 23 | 'Unidecode==0.4.19', 24 | ], 25 | packages=find_packages(), 26 | extras_require={ 27 | 'testing': [ 28 | 'pytest>=2.9.2' 29 | ] 30 | }, 31 | entry_points={ 32 | 'console_scripts': [ 33 | 'att-split-bill=attbillsplitter.entrypoints:split_bill', 34 | 'att-print-summary=attbillsplitter.entrypoints:print_summary', 35 | 'att-print-details=attbillsplitter.entrypoints:print_details', 36 | 'att-notify-users=attbillsplitter.entrypoints:notify_users', 37 | 'att-add-onetime-fee=attbillsplitter.entrypoints:add_onetime_fee', 38 | 'att-init-twilio=attbillsplitter.entrypoints:init_twilio', 39 | 'att-init-payment-msg=attbillsplitter.entrypoints:init_payment_msg' 40 | ], 41 | }, 42 | zip_safe=False 43 | ) 44 | -------------------------------------------------------------------------------- /attbillsplitter/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | """Database and Data models for att-bill-splitter.""" 3 | 4 | from peewee import * 5 | from attbillsplitter.utils import DATABASE_PATH 6 | 7 | db = SqliteDatabase(DATABASE_PATH) 8 | 9 | 10 | class BaseModel(Model): 11 | class Meta: 12 | database = db 13 | 14 | 15 | class User(BaseModel): 16 | name = CharField() 17 | number = CharField() 18 | created_at = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) 19 | 20 | class Meta: 21 | indexes = ( 22 | (('name', 'number'), True), 23 | ) 24 | 25 | 26 | class ChargeCategory(BaseModel): 27 | category = CharField(unique=True) 28 | text = CharField() 29 | created_at = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) 30 | 31 | 32 | class ChargeType(BaseModel): 33 | type = CharField() 34 | text = CharField() 35 | charge_category = ForeignKeyField(ChargeCategory) 36 | created_at = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) 37 | 38 | class Meta: 39 | indexes = ( 40 | (('type', 'charge_category'), True), 41 | ) 42 | 43 | 44 | class BillingCycle(BaseModel): 45 | name = CharField(unique=True) 46 | start_date = DateField(unique=True) 47 | end_date = DateField(unique=True) 48 | created_at = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) 49 | 50 | 51 | class Charge(BaseModel): 52 | user = ForeignKeyField(User) 53 | charge_type = ForeignKeyField(ChargeType) 54 | billing_cycle = ForeignKeyField(BillingCycle) 55 | amount = FloatField() 56 | created_at = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) 57 | 58 | class Meta: 59 | indexes = ( 60 | # create a unique on user, charge_type, billing_cycle 61 | (('user', 'charge_type', 'billing_cycle'), True), 62 | ) 63 | 64 | 65 | class MonthlyBill(BaseModel): 66 | user = ForeignKeyField(User, related_name='mb_user') 67 | billing_cycle = ForeignKeyField(BillingCycle, 68 | related_name='mb_billing_cycle') 69 | total = FloatField() 70 | created_at = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) 71 | -------------------------------------------------------------------------------- /attbillsplitter/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | """Utility methods""" 3 | 4 | from __future__ import print_function, unicode_literals 5 | from builtins import input 6 | try: 7 | import configparser 8 | except: 9 | import ConfigParser as configparser 10 | import os 11 | import sys 12 | import warnings 13 | 14 | CONFIG_PATH = os.path.expanduser('~/.attbillsplitter.conf') 15 | PAGE_LOADING_WAIT_S = 10 16 | DATABASE_PATH = 'att_bill.db' 17 | LOG_PATH = 'notif_history.log' 18 | warnings.simplefilter('ignore') 19 | 20 | 21 | def initialize_twiolio(): 22 | """Initialize twilio credentials from command line input and save in 23 | config file. 24 | 25 | :returns: None 26 | """ 27 | number = input('Twilio Number (e.g. +11234567890): ') 28 | account_sid = input('Twilio Account SID: ') 29 | auth_token = input('Twilio Authentication Token: ') 30 | config = configparser.ConfigParser() 31 | config.read(CONFIG_PATH) 32 | if config.remove_section('twilio'): 33 | print('\U00002B55 Old twilio credentials removed.'.encode("utf-8")) 34 | 35 | config.add_section('twilio') 36 | config.set('twilio', 'number', number) 37 | config.set('twilio', 'account_sid', account_sid) 38 | config.set('twilio', 'auth_token', auth_token) 39 | with open(CONFIG_PATH, 'w') as configfile: 40 | config.write(configfile) 41 | print('\U00002705 New twilio account added.'.encode("utf-8")) 42 | 43 | 44 | def load_twilio_config(): 45 | """Load twilio credentials. Prompt to initialize if not yet initialized. 46 | 47 | :returns: a tuple of twilio number, sid and auth token 48 | :rtype: tuple 49 | """ 50 | config = configparser.ConfigParser() 51 | config.read(CONFIG_PATH) 52 | # initialize twilio if not yet initialized 53 | if 'twilio' not in config.sections(): 54 | initialize_twiolio() 55 | config.read(CONFIG_PATH) 56 | 57 | number = config.get('twilio', 'number') 58 | account_sid = config.get('twilio', 'account_sid') 59 | auth_token = config.get('twilio', 'auth_token') 60 | return (number, account_sid, auth_token) 61 | 62 | 63 | def initialize_payment_msg(): 64 | """Initialize payment message to be appended to the charging details 65 | before sending to users (generally a mesaage to tell users how to pay you). 66 | 67 | :returns: None 68 | """ 69 | prompt_msg = ('You can enter a short message to put after the charge ' 70 | 'details to send to your users. (For example, letting your ' 71 | 'users know how to pay you)\n-> ') 72 | message = input(prompt_msg) 73 | config = configparser.ConfigParser() 74 | config.read(CONFIG_PATH) 75 | if config.remove_section('message'): 76 | print('\U00002B55 Old payment message removed.'.encode("utf-8")) 77 | config.add_section('message') 78 | config.set('message', 'payment', message) 79 | with open(CONFIG_PATH, 'w') as configfile: 80 | config.write(configfile) 81 | print('\U00002705 New payment message saved.'.encode("utf-8")) 82 | 83 | 84 | def load_payment_msg(): 85 | """Load payment message. Prompt to initialize if not yet initialized. 86 | 87 | :returns: payment message cached in config file 88 | :rtype: str 89 | """ 90 | config = configparser.ConfigParser() 91 | config.read(CONFIG_PATH) 92 | # initialize twilio if not yet initialized 93 | if ('message' not in config.sections() or 94 | 'payment' not in [t for (t, _) in config.items('message')]): 95 | initialize_payment_msg() 96 | config.read(CONFIG_PATH) 97 | 98 | else: 99 | message = config.get('message', 'payment') 100 | prompt = ('\U00002753 Do you want to keep using the following ' 101 | 'message: \n{}\n(y/n)? '.format(message)) 102 | try: 103 | # python3 104 | reset = input(prompt) 105 | except UnicodeEncodeError: 106 | # python2 107 | reset = input(prompt.encode(sys.stdout.encoding)) 108 | if reset in ('n', 'N', 'no', 'No', 'No'): 109 | initialize_payment_msg() 110 | config.read(CONFIG_PATH) 111 | return config.get('message', 'payment') 112 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | **THIS PROJECT IS NO LONGER MAINTAINED!** 2 | 3 | My original goal with this project was to automate the whole process of splitting AT&T family plan bills including the login, parsing, storage and SMS notification to each line, which now I found not realistic or maintainable because AT&T was updating their login process or bill format every few months. With this learning, now I use a hybrid approach which requires me to manually login, download the pdf bills, and copy some of the line items into a txt file, which is then parsed by a python script. Despite some manual effort, the total process takes less then 2 mins and I never have to update the script since it was first written more than a year ago. 4 | 5 | I used a private repo for the new script so that I could upload all the raw and parsed data there. Drop me a line if you want to give it a try. 6 | 7 | att-bill-splitter 8 | ================= 9 | 10 | Are you an AT&T account holder for multiple wireless lines and tired of manually splitting the bill, typing into spreadsheet and sending each of your user text message every month? Now you can automate all of that with this application. 11 | 12 | Overview 13 | -------- 14 | 15 | This package is written in Python (works with both Python 2 and 3) and uses requests and beautifulsoup4 to login your AT&T account and parse the bills. peewee is used as the ORM and data are stored in a Sqlite database. Command line interface is built with click. It also has twilio integration to send auto-generated monthly billing details to each user. 16 | 17 | Installation 18 | ------------ 19 | 20 | via pip 21 | ~~~~~~~ 22 | :: 23 | 24 | [~] pip install att-bill-splitter 25 | 26 | 27 | via source code 28 | ~~~~~~~~~~~~~~~ 29 | :: 30 | 31 | [~] git clone https://github.com/brianzq/att-bill-splitter.git 32 | [~] cd att-bill-splitter 33 | [att-bill-splitter] pip install . 34 | 35 | *I recommend using a virtualenv to isolate all the dependencies of this application from your local packages.* 36 | 37 | Quick Start 38 | ----------- 39 | 40 | Parse And Split Bills 41 | ~~~~~~~~~~~~~~~~~~~~~ 42 | This is the first thing you run. You will be prompted to input your AT&T username and password (within terminal). Once logged in, it will start parsing your previous bills, splitting them and storing data to database. 43 | :: 44 | 45 | [att-bill-splitter] att-split-bill 46 | 47 | For example 48 | :: 49 | 50 | [att-bill-splitter] att-split-bill 51 | 👤 AT&T Username: your_att_username 52 | 🗝 AT&T Password: 53 | ▶ Login started... 54 | ✅ Login succeeded. 55 | 🏃 Start splitting bill Sep 15 - Oct 14, 2016... 56 | 🏁 Finished splitting bill Sep 15 - Oct 14, 2016. 57 | 🏃 Start splitting bill Aug 15 - Sep 14, 2016... 58 | 🏁 Finished splitting bill Aug 15 - Sep 14, 2016. 59 | 🏃 Start splitting bill Jul 15 - Aug 14, 2016... 60 | 🏁 Finished splitting bill Jul 15 - Aug 14, 2016. 61 | 🏃 Start splitting bill Jun 15 - Jul 14, 2016... 62 | 🏁 Finished splitting bill Jun 15 - Jul 14, 2016. 63 | 🏃 Start splitting bill May 15 - Jun 14, 2016... 64 | 🏁 Finished splitting bill May 15 - Jun 14, 2016. 65 | 🏃 Start splitting bill Apr 15 - May 14, 2016... 66 | 🏁 Finished splitting bill Apr 15 - May 14, 2016. 67 | 68 | By default it parses all your previous bills. If you want to select a few bills to parse, you can use ``-l`` option. The value of the option is the lag of the bill compared to the most recent one. So ``0`` refers to the most recent bill, ``1`` is one month before that and so on. For example, 69 | :: 70 | 71 | [att-bill-splitter] att-split-bill -l 0 72 | 👤 AT&T Username: your_att_username 73 | 🗝 AT&T Password: 74 | ▶ Login started... 75 | ✅ Login succeeded. 76 | 🏃 Start splitting bill Sep 15 - Oct 14, 2016... 77 | 🏁 Finished splitting bill Sep 15 - Oct 14, 2016. 78 | 79 | You can supply multiple ``-l`` options at once. 80 | 81 | **NOTE**: If your users overused your plan's data, you will probably get charged $15 for each additional Gigabyte like I do. When that happens, the charges for the additional data usage are split among user who used more than their monthly share (monthly_total_allowance / number_of_user), proportionally to the extra amount used. The details will be printed when you run above command, like this: 82 | 83 | :: 84 | 85 | [att-bill-splitter] att-split-bill -l 0 86 | 👤 AT&T Username: your_att_username 87 | 🗝 AT&T Password: 88 | ▶ Login started... 89 | ✅ Login succeeded. 90 | 🏃 Start splitting bill Sep 15 - Oct 14, 2016... 91 | User USER_NAME_1 over used 0.1 GB data, will be charged extra $0.68 92 | User USER_NAME_3 over used 0.69 GB data, will be charged extra $4.70 93 | User USER_NAME_4 over used 0.3 GB data, will be charged extra $2.06 94 | User USER_NAME_7 over used 3.31 GB data, will be charged extra $22.57 95 | 🏁 Finished splitting bill Sep 15 - Oct 14, 2016. 96 | 97 | View Monthly Charges Summary for Users 98 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 99 | After you parsed the bills, you can view them in your terminal. The command below will print the monthly summary for each user. 100 | :: 101 | 102 | [att-bill-splitter] att-print-summary MONTH [year] 103 | 104 | ``MONTH`` (1-12) refers to the month of the end date of the billing cycle. For example if you want to view billing cycle is Sep 15 - Oct 14, ``MONTH`` should be ``10``. ``year`` (optional) should be 4-digit. For example, 105 | :: 106 | 107 | [att-bill-splitter] att-print-summary 8 108 | 109 | -------------------------------------------------------------- 110 | Charge Summary for Billing Cycle Jul 15 - Aug 14, 2016 111 | -------------------------------------------------------------- 112 | USER_NAME_1 (415-555-0001) Total: 72.99 113 | USER_NAME_3 (415-555-0003) Total: 62.67 114 | USER_NAME_4 (415-555-0004) Total: 31.42 115 | USER_NAME_5 (415-555-0005) Total: 31.42 116 | USER_NAME_6 (415-555-0006) Total: 72.99 117 | USER_NAME_7 (415-555-0007) Total: 32.42 118 | USER_NAME_8 (415-555-0008) Total: 31.42 119 | USER_NAME_9 (415-555-0009) Total: 61.42 120 | -------------------------------------------------------------- 121 | Wireless Total: 444.52 122 | 123 | View Monthly Charges Details for Users 124 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 125 | 126 | You can also view itemized charge details for each user. 127 | :: 128 | 129 | [att-bill-splitter] att-print-details MONTH [year] 130 | 131 | ``MONTH`` (1-12) refers to the month of the end date of the billing cycle. For example if you want to view billing cycle is Sep 15 - Oct 14, ``MONTH`` should be ``10``. ``year`` (optional) should be 4-digit. 132 | 133 | For example, 134 | :: 135 | 136 | [att-bill-splitter] att-print-details 8 -y 2016 137 | 138 | USER_NAME_1 (415-555-0001) 139 | - Monthly Charges 15.00 140 | - Equipment Charges 42.50 141 | - Surcharges & Fees 2.69 142 | - Government Fees & Taxes 2.66 143 | - Account Monthly Charges Share 10.14 144 | - Total 72.99 145 | 146 | USER_NAME_2 (415-555-0002) 147 | - Monthly Charges 15.00 148 | - Equipment Charges 37.50 149 | - Surcharges & Fees 2.69 150 | - Government Fees & Taxes 1.92 151 | - Account Monthly Charges Share 10.14 152 | - Total 67.25 153 | ... 154 | 155 | Send Monthly Charge Details to Users via SMS 156 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 157 | 158 | View each user's monthly charge details (and total) and decide if you want to send it to the user via SMS. 159 | 160 | During your first use, you will be prompted to input your Twilio number, account SID and authentication token. You can get them in a minute for free at www.twilio.com. You will also be asked to input a short message to put at the end of the text messages you send to your users, for instance, to tell your users how to pay you. All the info will be saved locally so you don't have to type them in the future, unless you want to update them. 161 | :: 162 | 163 | [att-bill-splitter] att-notify-users MONTH [YEAR] 164 | 165 | ``MONTH`` (1-12) refers to the month of the end date of the billing cycle. For example if you want to view billing cycle is Sep 15 - Oct 14, ``MONTH`` should be ``10``. ``YEAR`` (optional) should be 4-digit. 166 | 167 | For example, 168 | :: 169 | 170 | [att-bill-splitter] att-notify-users 8 --year 2016 171 | Twilio Number (e.g. +11234567890): your_twilio_number 172 | Twilio Account SID: your_account_sid 173 | Twilio Authentication Token: your_auth_token 174 | ✅ Twilio account added. 175 | You can enter a short message to put after the charge details to send to your users. (For example, letting your users know how to pay you) 176 | -> Please Venmo me at Brianz56. 177 | ✅ Payment message saved. 178 | 179 | 415-555-0001 180 | Hi USER_NAME_1 (415-555-0001), 181 | Your AT&T Wireless Charges for Jul 15 - Aug 14, 2016: 182 | - Monthly Charges 15.00 183 | - Equipment Charges 42.50 184 | - Surcharges & Fees 2.69 185 | - Government Fees & Taxes 2.66 186 | - Account Monthly Charges Share 10.14 187 | - Total 72.99 🤑 188 | 189 | Notify (y/n)? 190 | 191 | If you type ``y``, it will call Twilio API to send the message to user 1 @ 415-555-0001 with the extra payment message you inputed upfront. At the mean time, all messages sent are logged in ``notif_history.log`` file in ``att-bill-splitter`` directory to help you manage all the history activities. 192 | 193 | I'd like to hear your thoughts. You can `join our slack channel `_ if you have any questions or just want to say hi. 194 | -------------------------------------------------------------------------------- /attbillsplitter/services.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | """Service that aggregate monthly wireless charges for each line in account. 3 | """ 4 | 5 | from __future__ import print_function, unicode_literals 6 | from builtins import input 7 | import datetime as dt 8 | import logging 9 | import click 10 | import peewee as pw 11 | import warnings 12 | import attbillsplitter.utils as utils 13 | from slugify import slugify 14 | from twilio.rest import TwilioRestClient 15 | from twilio.exceptions import TwilioException 16 | from attbillsplitter.models import ( 17 | User, ChargeCategory, ChargeType, BillingCycle, Charge, MonthlyBill, db 18 | ) 19 | 20 | warnings.simplefilter('ignore') 21 | logger = logging.getLogger(__name__) 22 | logger.setLevel(logging.INFO) 23 | ch = logging.FileHandler(utils.LOG_PATH) 24 | ch.setLevel(logging.INFO) 25 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') 26 | ch.setFormatter(formatter) 27 | logger.addHandler(ch) 28 | 29 | 30 | def print_wireless_monthly_summary(month, year=None): 31 | """Get wireless monthly summary for all lines. Results will be printed 32 | to console. 33 | 34 | :param month: month (1 - 12) of the end date of billing cycle 35 | :type month: int 36 | :param year: year of the end of of billing cycle. Default to current year 37 | :type year: int 38 | :returns: None 39 | """ 40 | # year value default to current year 41 | year = year or dt.date.today().year 42 | if not BillingCycle.select().where( 43 | db.extract_date('month', BillingCycle.end_date) == month, 44 | db.extract_date('year', BillingCycle.end_date) == year 45 | ).exists(): 46 | print('No charge summary found for {}/{}. Please split the ' 47 | 'bill first'.format(year, month)) 48 | return 49 | 50 | bc = BillingCycle.select().where( 51 | db.extract_date('month', BillingCycle.end_date) == month, 52 | db.extract_date('year', BillingCycle.end_date) == year 53 | ).get() 54 | print('\n--------------------------------------------------------------') 55 | print(' Charge Summary for Billing Cycle {}'.format(bc.name)) 56 | print('--------------------------------------------------------------') 57 | query = ( 58 | User 59 | .select(User.name, 60 | User.number, 61 | MonthlyBill.total) 62 | .join(MonthlyBill) 63 | .where(MonthlyBill.billing_cycle_id == bc.id) 64 | .naive() 65 | ) 66 | wireless_total = 0 67 | for user in query.execute(): 68 | print(' {:^18s} ({}) Total: {:.2f}'.format( 69 | user.name, user.number, user.total 70 | )) 71 | wireless_total += user.total 72 | print('--------------------------------------------------------------') 73 | print('{:>47}: {:.2f}\n'.format('Wireless Total', wireless_total)) 74 | 75 | 76 | def print_wireless_monthly_details(month, year=None): 77 | """Get wireless monthly details for all lines. Results will be printed 78 | to console. 79 | 80 | :param month: month (1 - 12) of the end date of billing cycle 81 | :type month: int 82 | :param year: year of the end of of billing cycle. Default to current year 83 | :type year: int 84 | :returns: None 85 | """ 86 | # year value default to current year 87 | year = year or dt.date.today().year 88 | if not BillingCycle.select().where( 89 | db.extract_date('month', BillingCycle.end_date) == month, 90 | db.extract_date('year', BillingCycle.end_date) == year 91 | ).exists(): 92 | print('No charge summary found for {}/{}. Please split the ' 93 | 'bill first'.format(year, month)) 94 | return 95 | 96 | bc = BillingCycle.select().where( 97 | db.extract_date('month', BillingCycle.end_date) == month, 98 | db.extract_date('year', BillingCycle.end_date) == year 99 | ).get() 100 | query = ( 101 | User 102 | .select(User.id, 103 | User.name, 104 | User.number, 105 | ChargeType.text.alias('charge_type'), 106 | pw.fn.SUM(Charge.amount).alias('total')) 107 | .join(Charge) 108 | .join(BillingCycle) 109 | .switch(Charge) 110 | .join(ChargeType) 111 | .join(ChargeCategory) 112 | .where(BillingCycle.id == bc.id, 113 | ChargeCategory.category == 'wireless') 114 | .group_by(User, BillingCycle, ChargeType) 115 | .order_by(User.id) 116 | .naive() 117 | ) 118 | current_user_num = '' 119 | current_user_total = 0 120 | wireless_total = 0 121 | print('') 122 | for user in query.execute(): 123 | if user.number != current_user_num: 124 | if current_user_total: 125 | print(' - {:40} {:.2f}\n'.format('Total', 126 | current_user_total)) 127 | wireless_total += current_user_total 128 | current_user_num = user.number 129 | current_user_total = 0 130 | print(' {} ({})'.format(user.name, user.number)) 131 | print(' - {:40} {:.2f}'.format(user.charge_type, user.total)) 132 | current_user_total += user.total 133 | if current_user_total: 134 | print(' - {:40} {:.2f}\n'.format('Total', current_user_total)) 135 | wireless_total += current_user_total 136 | print('{:>48}: {:.2f}\n'.format('Wireless Total', wireless_total)) 137 | 138 | 139 | def notify_users_monthly_details(message_client, payment_msg, month, 140 | year=None): 141 | """Calculate monthly charge details for users and notify them. 142 | 143 | :param message_client: a message client to send text message 144 | :type message_client: MessageClient 145 | :param payment_message: text appended to charge details so that your 146 | users know how to pay you. 147 | :param type: str 148 | :param month: month (1 - 12) of the end date of billing cycle 149 | :type month: int 150 | :param year: year of the end of of billing cycle. Default to current year 151 | :type year: int 152 | :returns: None 153 | """ 154 | # year value default to current year 155 | year = year or dt.date.today().year 156 | if not BillingCycle.select().where( 157 | db.extract_date('month', BillingCycle.end_date) == month, 158 | db.extract_date('year', BillingCycle.end_date) == year 159 | ).exists(): 160 | print('No charge summary found for {}/{}. Please split the ' 161 | 'bill first'.format(year, month)) 162 | return 163 | 164 | bc = BillingCycle.select().where( 165 | db.extract_date('month', BillingCycle.end_date) == month, 166 | db.extract_date('year', BillingCycle.end_date) == year 167 | ).get() 168 | query = ( 169 | User 170 | .select(User.id, 171 | User.name, 172 | User.number, 173 | ChargeType.text.alias('charge_type'), 174 | pw.fn.SUM(Charge.amount).alias('total')) 175 | .join(Charge) 176 | .join(BillingCycle) 177 | .switch(Charge) 178 | .join(ChargeType) 179 | .join(ChargeCategory) 180 | .where(BillingCycle.id == bc.id, 181 | ChargeCategory.category == 'wireless') 182 | .group_by(User, BillingCycle, ChargeType) 183 | .order_by(User.id) 184 | .naive() 185 | ) 186 | current_user_num = -1 187 | current_user_total = 0 188 | messages = {} 189 | message = '' 190 | print('') 191 | for user in query.execute(): 192 | if user.number != current_user_num: 193 | if current_user_total: 194 | message += ' - {:30} {:.2f} \U0001F911\n'.format( 195 | 'Total', current_user_total 196 | ) 197 | messages[current_user_num] = message 198 | current_user_num = user.number 199 | current_user_total = 0 200 | message = ('Hi {} ({}),\nYour AT&T Wireless Charges ' 201 | 'for {}:\n'.format(user.name, user.number, bc.name)) 202 | message += ' - {:30} {:.2f}\n'.format(user.charge_type, user.total) 203 | current_user_total += user.total 204 | if current_user_total: 205 | message += ' - {:30} {:.2f} \U0001F911\n'.format('Total', 206 | current_user_total) 207 | messages[current_user_num] = message 208 | # print message for user to confirm 209 | for num, msg in messages.items(): 210 | print(num) 211 | print(msg) 212 | notify = input('Notify (y/n)? ') 213 | if notify in ('', 'y', 'Y', 'yes', 'Yes', 'YES'): 214 | body = '{}\n{}'.format(msg, payment_msg) 215 | message_client.send_message(body=body, to=num) 216 | logger.info('%s charge details sent to %s, body:\n%s', 217 | bc.name, num, msg) 218 | print('\U00002705 Message sent to {}\n'.format(num).encode("utf-8")) 219 | 220 | 221 | class MessageClient(object): 222 | """Twilio message client that sends text message to users.""" 223 | def __init__(self): 224 | try: 225 | number, account_sid, auth_token = utils.load_twilio_config() 226 | self.number = number 227 | self.twilio_client = TwilioRestClient(account_sid, auth_token) 228 | except TwilioException: 229 | print('\U0001F6AB Current twilio credentials invalid. ' 230 | 'Please reset.'.encode("utf-8")) 231 | utils.initialize_twiolio() 232 | number, account_sid, auth_token = utils.load_twilio_config() 233 | self.number = number 234 | self.twilio_client = TwilioRestClient(account_sid, auth_token) 235 | 236 | def send_message(self, body, to): 237 | """Send message body from self.number to a phone number. 238 | 239 | :param body: message body to send 240 | :type body: str 241 | :param to: number to send message to (123-456-789) 242 | :type to: str 243 | :returns None 244 | """ 245 | self.twilio_client.messages.create(body=body, to=to, from_=self.number) 246 | 247 | 248 | @click.command() 249 | @click.argument('month', type=int) 250 | @click.option('-y', '--year', type=int, 251 | help='Year of the end date of the billing cycle (YYYY)') 252 | def run_print_summary(month, year): 253 | """Print monthly charge summary for each user. 254 | 255 | MONTH refers to the month of the end date of the billing cycle. 256 | It should be an integer from 1 to 12. You can also specify --year (YYYY). 257 | By default, YEAR is set to current calendar year. 258 | """ 259 | print_wireless_monthly_summary(month, year) 260 | 261 | 262 | @click.command() 263 | @click.argument('month', type=int) 264 | @click.option('-y', '--year', type=int, 265 | help='Year of the end date of the billing cycle (YYYY)') 266 | def run_print_details(month, year): 267 | """Print monthly charge details for each user. 268 | 269 | MONTH refers to the month of the end date of the billing cycle. 270 | It should be an integer from 1 to 12. You can also specify --year (YYYY). 271 | By default, YEAR is set to current calendar year. 272 | """ 273 | print_wireless_monthly_details(month, year) 274 | 275 | 276 | @click.command() 277 | @click.argument('month', type=int) 278 | @click.option('-y', '--year', type=int, 279 | help='Year of the end date of the billing cycle (YYYY)') 280 | def run_notify_users(month, year): 281 | """Send monthly charge details to each user via SMS. 282 | 283 | For each user, you will first be shown his charge details, then you can 284 | decide whether you want to notify him/her. MONTH refers to the month of 285 | the end date of the billing cycle. It should be an integer from 1 to 12. 286 | You can also specify --year (YYYY). By default, YEAR is set to 287 | current calendar year. 288 | """ 289 | mc = MessageClient() 290 | payment_msg = utils.load_payment_msg() 291 | notify_users_monthly_details(mc, payment_msg, month, year) 292 | 293 | 294 | @click.command() 295 | @click.argument('amount', type=float) 296 | @click.argument('charge_name', type=str) 297 | def add_onetime_fee(amount, charge_name): 298 | """Add one time charge to all users. For example, we can use this to add 299 | a $2 annual Twilio fee""" 300 | charge_type = slugify(charge_name) 301 | ct = ChargeType.create(type=charge_type, 302 | text=charge_name, 303 | charge_category=1) 304 | ct.save() 305 | bc = BillingCycle.select().order_by(BillingCycle.id.desc()).get() 306 | 307 | with db.atomic(): 308 | for user in User.select(): 309 | Charge.create(user=user, charge_type=ct, billing_cycle=bc, 310 | amount=amount).save() 311 | print('{} {} added for user {}'.format(amount, charge_name, user.name)) 312 | -------------------------------------------------------------------------------- /attbillsplitter/main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Main module""" 3 | 4 | from __future__ import print_function, unicode_literals 5 | from builtins import input 6 | import datetime as dt 7 | import re 8 | import click 9 | import peewee as pw 10 | import requests 11 | from bs4 import BeautifulSoup, Tag 12 | from slugify import slugify 13 | 14 | # import fake_useragent 15 | from attbillsplitter.errors import ParsingError 16 | from attbillsplitter.models import ( 17 | User, ChargeCategory, ChargeType, BillingCycle, Charge, MonthlyBill, db 18 | ) 19 | 20 | 21 | CHROME_AGENT = ('Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 ' 22 | '(KHTML, like Gecko) Chrome/28.0.1468.0 Safari/537.36') 23 | # CHROME_AGENT = fake_useragent.UserAgent().chrome 24 | 25 | 26 | def create_tables_if_not_exist(): 27 | """Create tables in database if tables do not exist. 28 | 29 | Tables will be created for following models: 30 | - User 31 | - ChargeCategory 32 | - ChargeType 33 | - BillingCycle 34 | - Charge 35 | - MonthlyBill 36 | """ 37 | db.connect() 38 | for model in (User, ChargeCategory, ChargeType, BillingCycle, Charge, 39 | MonthlyBill): 40 | if not model.table_exists(): 41 | model.create_table() 42 | 43 | 44 | def get_start_end_date(bc_name): 45 | """Get start date and end date for a billing cycle name using regex. 46 | 47 | :param bc_name: name of billing cycle in format of 48 | 'Mar 15 - Apr 14, 2016' 49 | :type bc_name: str 50 | :returns: a tuple of start date (datetime.date) and end date 51 | :rtype: tuple 52 | """ 53 | m = re.search(r'(\w+ \d+) - (\w+ \d+), (\d{4})', bc_name) 54 | start_date_str = '{}, {}'.format(m.group(1), m.group(3)) 55 | end_date_str = '{}, {}'.format(m.group(2), m.group(3)) 56 | start_date = dt.datetime.strptime(start_date_str, '%b %d, %Y').date() 57 | end_date = dt.datetime.strptime(end_date_str, '%b %d, %Y').date() 58 | return (start_date, end_date) 59 | 60 | 61 | def aggregate_wireless_monthly(bc): 62 | """Aggregate wireless charges among all lines. 63 | 64 | :param bc: billing_cycle object 65 | :type bc: BillingCycle 66 | :returns: None 67 | """ 68 | if MonthlyBill.select().where(MonthlyBill.billing_cycle == bc).exists(): 69 | print('\U000026A0 Charges already aggregated for {}'.format( 70 | bc.name).encode("utf-8")) 71 | return 72 | 73 | query = ( 74 | User 75 | .select(User, 76 | pw.fn.SUM(Charge.amount).alias('total')) 77 | .join(Charge) 78 | .join(BillingCycle) 79 | .switch(Charge) 80 | .join(ChargeType) 81 | .join(ChargeCategory) 82 | .where(BillingCycle.id == bc.id, 83 | ChargeCategory.category == 'wireless') 84 | .group_by(User, BillingCycle) 85 | .naive() 86 | ) 87 | results = query.execute() 88 | for user in results: 89 | MonthlyBill.create(user=user, billing_cycle=bc, total=user.total) 90 | 91 | 92 | class AttBillSplitter(object): 93 | """Parse AT&T bill and split wireless charges among users. 94 | 95 | Currently tested on AT&T account with U-verse TV, Internet and Mobile 96 | Share Value Plan (for wireless). 97 | """ 98 | 99 | def __init__(self, username, password): 100 | self.username = username 101 | self.password = password 102 | self.session = requests.session() 103 | headers = {'User-Agent': CHROME_AGENT} 104 | self.session.headers.update(headers) 105 | 106 | def login(self): 107 | """Login to your AT&T online account. 108 | 109 | :returns: status of the login (True or False) 110 | :rtype: bool 111 | """ 112 | print('\U000025B6 Login started...'.encode("utf-8")) 113 | login_url = ( 114 | 'https://myattdx05.att.com/commonLogin/igate_wam/multiLogin.do' 115 | ) 116 | # obtain session cookies needed to login 117 | self.session.get(login_url) 118 | # soup = BeautifulSoup(pre_login.text, 'html.parser') 119 | # hidden_inputs = soup.find_all('input', type='hidden') 120 | # form = { 121 | # x.get('name'): x.get('value') 122 | # for x in hidden_inputs if x.get('value') and x.get('name') 123 | # } 124 | 125 | # this information is in a comment tag and is very difficult to parse 126 | # hard code this for now 127 | form = { 128 | 'source': 'MYATT', 129 | 'flow_ind': 'LGN', 130 | 'isSlidLogin': 'true', 131 | 'vhname': 'www.att.com', 132 | 'urlParameters': ('fromdlom=true&reportActionEvent=A_LGN_LOGIN_SUB' 133 | '&loginSource=olam'), 134 | 'myATTIntercept': 'false', 135 | 'persist': 'Y', 136 | 'rootPath': '/olam/English' 137 | } 138 | form.update({'userid': self.username, 'password': self.password}) 139 | login_submit = self.session.post(login_url, data=form) 140 | # open('test.html', 'w').write(login_submit.text.encode('utf-8')) 141 | if 'Manage your account' in login_submit.text: 142 | print('\U00002705 Login succeeded.'.encode("utf-8")) 143 | return True 144 | else: 145 | if 'promo' in login_submit.url.lower(): 146 | if not self.click_skip_promo(): 147 | print ('\U0001F534 Popup window detected during login. ' 148 | 'Please login you account in a browser and click ' 149 | 'through. Log out your account before you retry. ' 150 | '(Sometimes you might have to do this multiple times.'.encode("utf-8")) 151 | return False 152 | return True 153 | 154 | print('\U0001F6AB Login failed. Please check your username and ' 155 | 'password and retry. Or something unexpected happened.'.encode("utf-8")) 156 | return False 157 | 158 | def click_skip_promo(self): 159 | """Request to skip the promo popup 160 | 161 | :returns: status of the skip request (True or False) 162 | :rtype: bool 163 | """ 164 | reject_promo_link = ( 165 | 'https://www.att.com/olam/rejectPromoUserResponse.myworld' 166 | ) 167 | skip_promo = self.session.get(reject_promo_link, params={ 168 | 'response': 'rejected', 169 | 'reportActionEvent': 'A_LGN_PROMO_DECLINED_SUB' 170 | }) 171 | return skip_promo.status_code == requests.codes.ok 172 | 173 | def get_history_bills(self): 174 | """Get history bills. 175 | 176 | :yields: tuple of billing_cycle name and link to the bill 177 | """ 178 | # this request will add some cookie 179 | self.session.get( 180 | 'https://www.att.com/olam/passthroughAction.myworld', 181 | params={'actionType': 'ViewBillHistory'} 182 | ) 183 | 184 | # obtain account number from bill detail page 185 | # seems like for accounts with uverse services, the link to get the account 186 | # number is different, so is link to bill. 187 | # we first try to get account number assuming you have only wireless service 188 | # if that fails, we will try the uverse option. 189 | uverse_url = ('https://www.att.com/olam/acctInfoView.myworld?' 190 | 'actionEvent=displayProfileInformation') 191 | wireless_url = 'https://www.att.com/olam/ViewBillDetailsAction.myworld' 192 | an_req = self.session.get(wireless_url) 193 | act_num_full = re.search( 194 | 'wirelessAccountNumber":"[0-9]+"', an_req.text) 195 | if act_num_full: 196 | bill_statement_id_template = '{}|{}|T01|W' 197 | try: 198 | act_num_str = act_num_full.group(0) 199 | except AttributeError as e: 200 | print( 201 | 'Something went wrong. Could not find account number from bill detail page.') 202 | raise ParsingError('Account number not found!') 203 | act_num = re.search('[0-9]+', act_num_str).group(0) 204 | else: 205 | an_req = self.session.get(uverse_url) 206 | bill_statement_id_template = '{}|{}|T06|V' 207 | an_soup = BeautifulSoup(an_req.text, 'html.parser') 208 | act_num_tag = an_soup.find('span', class_='account-number') 209 | m = re.search(r'.?(\d+).?', act_num_tag.text, re.DOTALL) 210 | if not m: 211 | print( 212 | 'Something went wrong. Could not find account number from bill detail page.') 213 | raise ParsingError('Account number not found!') 214 | act_num = m.group(1) 215 | 216 | # now we can get billing history 217 | bh_req = self.session.get( 218 | 'https://www.att.com/olam/billingPaymentHistoryAction.myworld', 219 | params={'action': 'ViewBillHistory'} 220 | ) 221 | bh_req.raise_for_status() 222 | bh_soup = BeautifulSoup(bh_req.text, 'html.parser') 223 | bc_tags = bh_soup.find_all('td', headers=['bill_period']) 224 | for tag in bc_tags: 225 | bc_name = tag.contents[0] 226 | end_date_name = bc_name.split(' - ')[1] 227 | end_date = dt.datetime.strptime(end_date_name, '%b %d, %Y') 228 | end_date_str = end_date.strftime('%Y%m%d') 229 | bill_statement_id = bill_statement_id_template.format( 230 | end_date_str, act_num) 231 | yield (bc_name, bill_statement_id) 232 | 233 | def parse_user_info(self, bill_html): 234 | """Parse the bill to find name and number for each line and create 235 | users. Account holder should be the first entry. 236 | 237 | :returns: list of user objects 238 | :rtype: list 239 | :returns: None 240 | """ 241 | users = [] 242 | soup = BeautifulSoup(bill_html, 'html.parser') 243 | number_tags = soup.find_all('div', string=re.compile('Total for')) 244 | for num_tag in number_tags: 245 | number = num_tag.text.lstrip('Total for') 246 | # get name for number 247 | name_tag = soup.find('div', class_='accRow bold MarTop10', 248 | string=re.compile('{}'.format(number))) 249 | name = name_tag.text.rstrip(' {}'.format(number)) 250 | user, _ = User.get_or_create(name=name, number=number) 251 | users.append(user) 252 | return users 253 | 254 | def split_bill(self, bc_name, bill_statement_id): 255 | """Parse bill and split wireless charges among users. 256 | 257 | Currently not parsing U-Verse charges. 258 | :param bc_name: billing cycle name 259 | :type bc_name: str 260 | :param bill_statement_id: bill statement id, used in link 261 | :type bill_statement_id: str 262 | :returns: None 263 | """ 264 | bill_link_template = ( 265 | 'https://www.att.com/olam/billPrintPreview.myworld?' 266 | 'fromPage=history&billStatementID={}' 267 | ) 268 | bill_req = self.session.get( 269 | bill_link_template.format(bill_statement_id)) 270 | bill_html = bill_req.text 271 | if 'Account Details' not in bill_html: 272 | raise ParsingError('Failed to retrieve billing page') 273 | 274 | soup = BeautifulSoup(bill_html, 'html.parser') 275 | start_date, end_date = get_start_end_date(bc_name) 276 | billing_cycle = BillingCycle.create(name=bc_name, 277 | start_date=start_date, 278 | end_date=end_date) 279 | 280 | # parse user name and number 281 | users = self.parse_user_info(bill_html) 282 | if not users: 283 | return 284 | 285 | message = 'Choose account holder\'s number\n' 286 | for i, user in enumerate(users): 287 | message += '{}: {}\n'.format(i, user.number) 288 | 289 | ind = input(message + '> ') 290 | try: 291 | account_holder = users[int(ind)] 292 | except: 293 | print('You have to input a number from 0 to {}'.format(len(users) - 1)) 294 | # -------------------------------------------------------------------- 295 | # Wireless 296 | # -------------------------------------------------------------------- 297 | wireless_charge_category, _ = ChargeCategory.get_or_create( 298 | category='wireless', 299 | text='Wireless' 300 | ) 301 | charged_users = [account_holder] 302 | name, number = account_holder.name, account_holder.number 303 | offset = 0.0 304 | # charge section starts with user name followed by his/her number 305 | target = soup.find('div', 306 | string=re.compile('{} {}'.format(name, number))) 307 | # fetch data usage in case there is an overage 308 | usage_req = self.session.post( 309 | 'https://www.att.com/olam/billUsageTiles.myworld', 310 | data={'billStatementID': bill_statement_id} 311 | ) 312 | usage_soup = BeautifulSoup(usage_req.text, 'html.parser') 313 | usages = {} 314 | overage_charge_type_name = 'data-text-usage-charges' 315 | overage_charge_type_text = 'Data & Text Usage Charges' 316 | data_overused = False 317 | for tag in target.parent.next_siblings: 318 | # all charge data are in divs 319 | if not isinstance(tag, Tag) or tag.name != 'div': 320 | continue 321 | 322 | # charge section ends with Total for number 323 | if 'Total for {}'.format(number) in tag.text: 324 | break 325 | 326 | # each charge type has 'accSummary' as one of its css classes 327 | if 'accSummary' in tag.get('class', []): 328 | charge_type_text = tag.find('div').text.strip('\n\t') 329 | if charge_type_text.startswith('Monthly Charges'): 330 | charge_type_text = 'Monthly Charges' 331 | # account monthly fee will be shared by all users 332 | w_act_m = float( 333 | re.search(r'\$([0-9.]+)', tag.text).group(1) 334 | ) 335 | # national discount is applied to account monthly fee 336 | m = re.search( 337 | r'National Account Discount.*?\$([0-9.]+)', 338 | tag.text, re.DOTALL 339 | ) 340 | w_act_m_disc = float(m.group(1)) if m else 0.0 341 | # this non-zero offset will be used to adjust account 342 | # holder's total monthly charge 343 | offset = w_act_m - w_act_m_disc 344 | 345 | m = re.search( 346 | r'Total {}.*?\$([0-9.]+)'.format(charge_type_text), 347 | tag.text, 348 | flags=re.DOTALL 349 | ) 350 | charge_type_name = slugify(charge_type_text) 351 | 352 | # check if it's a data overage which needs to be shared proportionaly 353 | if charge_type_name == overage_charge_type_name: 354 | data_overused = True 355 | total_overage_charge = float(m.group(1)) 356 | user_tag = usage_soup.find( 357 | 'p', string=re.compile(account_holder.name)) 358 | usage_tag = list( 359 | user_tag.parent.parent.parent.next_siblings)[1] 360 | usage = float(usage_tag.findChild('strong').text) 361 | usages[account_holder] = usage 362 | total_data_allowance = float( 363 | list(usage_tag.findChild( 364 | 'strong').next_siblings)[-1].split()[0] 365 | ) 366 | else: 367 | charge_total = float(m.group(1)) - offset 368 | # save data to db 369 | # ChargeType 370 | charge_type, _ = ChargeType.get_or_create( 371 | type=charge_type_name, 372 | text=charge_type_text, 373 | charge_category=wireless_charge_category 374 | ) 375 | # Charge 376 | new_charge = Charge( 377 | user=account_holder, 378 | charge_type=charge_type, 379 | billing_cycle=billing_cycle, 380 | amount=charge_total 381 | ) 382 | new_charge.save() 383 | offset = 0.0 384 | 385 | # iterate regular users 386 | remaining_users = [u for u in users if u.number != number] 387 | for user in remaining_users: 388 | charge_total = 0.0 389 | name, number = user.name, user.number 390 | # charge section starts with user name followed by his/her number 391 | target = soup.find('div', 392 | string=re.compile('{} {}'.format(name, number))) 393 | for tag in target.parent.next_siblings: 394 | # all charge data are in divs 395 | if not isinstance(tag, Tag) or tag.name != 'div': 396 | continue 397 | 398 | # charge section ends with Total for number 399 | if 'Total for {}'.format(number) in tag.text: 400 | break 401 | 402 | # each charge type has 'accSummary' as one of its css classes 403 | if 'accSummary' in tag.get('class', []): 404 | charge_type_text = tag.find('div').text.strip('\n\t') 405 | if charge_type_text.startswith('Monthly Charges'): 406 | charge_type_text = 'Monthly Charges' 407 | 408 | m = re.search( 409 | r'Total {}.*?\$([0-9.]+)'.format(charge_type_text), 410 | tag.text, 411 | flags=re.DOTALL 412 | ) 413 | charge_total = float(m.group(1)) 414 | # save data to db 415 | charge_type_name = slugify(charge_type_text) 416 | # ChargeType 417 | charge_type, _ = ChargeType.get_or_create( 418 | type=charge_type_name, 419 | text=charge_type_text, 420 | charge_category=wireless_charge_category 421 | ) 422 | # Charge 423 | new_charge = Charge( 424 | user=user, 425 | charge_type=charge_type, 426 | billing_cycle=billing_cycle, 427 | amount=charge_total 428 | ) 429 | new_charge.save() 430 | if charge_total > 0: 431 | charged_users.append(user) 432 | charge_type, _ = ChargeType.get_or_create( 433 | type='data-text-usage-charges', 434 | text='Data & Text Usage Charges', 435 | charge_category=wireless_charge_category 436 | ) 437 | # data usages 438 | user_tag = usage_soup.find('p', string=re.compile(user.name)) 439 | if user_tag: 440 | usage_tag = list( 441 | user_tag.parent.parent.parent.next_siblings)[1] 442 | usage = float(usage_tag.findChild('strong').text) 443 | usages[user] = usage 444 | 445 | # update share of account monthly charges for each user 446 | # also calculate total wireless charges (for verification later) 447 | act_m_share = (w_act_m - w_act_m_disc) / len(charged_users) 448 | wireless_total = 0 449 | for user in charged_users: 450 | # ChargeType 451 | charge_type, _ = ChargeType.get_or_create( 452 | type='wireless-acount-monthly-charges-share', 453 | text='Account Monthly Charges Share', 454 | charge_category=wireless_charge_category 455 | ) 456 | # Charge 457 | new_charge = Charge( 458 | user=user, 459 | charge_type=charge_type, 460 | billing_cycle=billing_cycle, 461 | amount=act_m_share 462 | ) 463 | new_charge.save() 464 | user_total = Charge.select( 465 | pw.fn.Sum(Charge.amount).alias('total') 466 | ).join( 467 | ChargeType, 468 | on=Charge.charge_type_id == ChargeType.id 469 | ).where( 470 | (Charge.user == user), 471 | Charge.billing_cycle == billing_cycle, 472 | ChargeType.charge_category == wireless_charge_category 473 | ) 474 | wireless_total += user_total[0].total 475 | 476 | if data_overused: 477 | user_share = total_data_allowance / len(charged_users) 478 | overages = {k: v - user_share for k, 479 | v in usages.iteritems() if v > user_share} 480 | total_overage = sum(overages.values()) 481 | for user, overage in overages.iteritems(): 482 | overage_charge = overage / total_overage * total_overage_charge 483 | print('User {} over used {} GB data, will be charged extra ${:.2f}'.format( 484 | user.name, overage, overage_charge) 485 | ) 486 | charge_type, _ = ChargeType.get_or_create( 487 | type=overage_charge_type_name, 488 | text=overage_charge_type_text, 489 | charge_category=wireless_charge_category 490 | ) 491 | # Charge 492 | new_charge = Charge( 493 | user=user, 494 | charge_type=charge_type, 495 | billing_cycle=billing_cycle, 496 | amount=overage_charge 497 | ) 498 | new_charge.save() 499 | # aggregate 500 | aggregate_wireless_monthly(billing_cycle) 501 | 502 | def run(self, lag, force): 503 | """ 504 | :param lag: a list of lags indicating which bills to split 505 | :type lag: list 506 | :param force: a flag to force splitting the bill 507 | :type force: bool 508 | :returns: None 509 | """ 510 | if not self.login(): 511 | return 512 | 513 | for i, (bc_name, bill_statement_id) in enumerate(self.get_history_bills()): 514 | # if lag is not empty, only split bills specified 515 | if lag and (i not in lag) and not force: 516 | continue 517 | 518 | # check if billing cycle already exist 519 | if BillingCycle.select().where( 520 | BillingCycle.name == bc_name 521 | ): 522 | print('\U000026A0 Billing Cycle {} already ' 523 | 'processed.'.format(bc_name).encode("utf-8")) 524 | continue 525 | 526 | print('\U0001F3C3 Start splitting bill {}...'.format( 527 | bc_name).encode("utf-8")) 528 | self.split_bill(bc_name, bill_statement_id) 529 | print('\U0001F3C1 Finished splitting bill {}.'.format( 530 | bc_name).encode("utf-8")) 531 | 532 | 533 | @click.command() 534 | @click.option('--lag', '-l', multiple=True, type=int, 535 | help=('Lag of the bill you want to split with respect to ' 536 | 'current bill. 0 refers to the most recent bill, 1 ' 537 | 'refers to the bill from previous month. Can be used ' 538 | 'multiple times.')) 539 | @click.option('--force', '-f', default=False, 540 | help=('Force to split the bill (and save) even if the bill has ' 541 | 'already been split before.')) 542 | @click.option('--username', prompt='\U0001F464 AT&T Username', 543 | help='Username') 544 | @click.option('--password', prompt='\U0001F5DD AT&T Password', 545 | hide_input=True, help='Password') 546 | def run_split_bill(username, password, lag, force): 547 | """Split AT&T wireless bills among lines. 548 | 549 | By default all new (unsplit) bills will be split. If you want to select 550 | bills to split, use the --lag (-l) option. 551 | """ 552 | create_tables_if_not_exist() 553 | splitter = AttBillSplitter(username, password) 554 | splitter.run(lag, force) 555 | --------------------------------------------------------------------------------