Repository: softlayer/softlayer-python
Pull request #1120 (2019-03-21 23:43:24)

Invoice and event CLI
New Commands
+ account events
+ account event-detail
+ account invoices
+ account invoice-detail
+ account summary

New Manager
+ account


Patch:
diff --git a/SoftLayer/CLI/account/__init__.py b/SoftLayer/CLI/account/__init__.py
new file mode 100644
index 00000000..50da7c7f
--- /dev/null
+++ b/SoftLayer/CLI/account/__init__.py
@@ -0,0 +1,1 @@
+"""Account commands"""
diff --git a/SoftLayer/CLI/account/event_detail.py b/SoftLayer/CLI/account/event_detail.py
new file mode 100644
index 00000000..2c1ee80c
--- /dev/null
+++ b/SoftLayer/CLI/account/event_detail.py
@@ -0,0 +1,73 @@
+"""Details of a specific event, and ability to acknowledge event."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+from SoftLayer.managers.account import AccountManager as AccountManager
+from SoftLayer import utils
+
+
+@click.command()
+@click.argument('identifier')
+@click.option('--ack', is_flag=True, default=False,
+              help="Acknowledge Event. Doing so will turn off the popup in the control portal")
+@environment.pass_env
+def cli(env, identifier, ack):
+    """Details of a specific event, and ability to acknowledge event."""
+
+    # Print a list of all on going maintenance
+    manager = AccountManager(env.client)
+    event = manager.get_event(identifier)
+
+    if ack:
+        manager.ack_event(identifier)
+
+    env.fout(basic_event_table(event))
+    env.fout(impacted_table(event))
+    env.fout(update_table(event))
+
+
+def basic_event_table(event):
+    """Formats a basic event table"""
+    table = formatting.Table(["Id", "Status", "Type", "Start", "End"],
+                             title=utils.clean_splitlines(event.get('subject')))
+
+    table.add_row([
+        event.get('id'),
+        utils.lookup(event, 'statusCode', 'name'),
+        utils.lookup(event, 'notificationOccurrenceEventType', 'keyName'),
+        utils.clean_time(event.get('startDate')),
+        utils.clean_time(event.get('endDate'))
+    ])
+
+    return table
+
+
+def impacted_table(event):
+    """Formats a basic impacted resources table"""
+    table = formatting.Table([
+        "Type", "Id", "Hostname", "PrivateIp", "Label"
+    ])
+    for item in event.get('impactedResources', []):
+        table.add_row([
+            item.get('resourceType'),
+            item.get('resourceTableId'),
+            item.get('hostname'),
+            item.get('privateIp'),
+            item.get('filterLabel')
+        ])
+    return table
+
+
+def update_table(event):
+    """Formats a basic event update table"""
+    update_number = 0
+    for update in event.get('updates', []):
+        header = "======= Update #%s on %s =======" % (update_number, utils.clean_time(update.get('startDate')))
+        click.secho(header, fg='green')
+        update_number = update_number + 1
+        text = update.get('contents')
+        # deals with all the \r\n from the API
+        click.secho(utils.clean_splitlines(text))
diff --git a/SoftLayer/CLI/account/events.py b/SoftLayer/CLI/account/events.py
new file mode 100644
index 00000000..5cc91144
--- /dev/null
+++ b/SoftLayer/CLI/account/events.py
@@ -0,0 +1,54 @@
+"""Summary and acknowledgement of upcoming and ongoing maintenance events"""
+# :license: MIT, see LICENSE for more details.
+import click
+
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+from SoftLayer.managers.account import AccountManager as AccountManager
+from SoftLayer import utils
+
+
+@click.command()
+@click.option('--ack-all', is_flag=True, default=False,
+              help="Acknowledge every upcoming event. Doing so will turn off the popup in the control portal")
+@environment.pass_env
+def cli(env, ack_all):
+    """Summary and acknowledgement of upcoming and ongoing maintenance events"""
+
+    manager = AccountManager(env.client)
+    events = manager.get_upcoming_events()
+
+    if ack_all:
+        for event in events:
+            result = manager.ack_event(event['id'])
+            event['acknowledgedFlag'] = result
+    env.fout(event_table(events))
+
+
+def event_table(events):
+    """Formats a table for events"""
+    table = formatting.Table([
+        "Id",
+        "Start Date",
+        "End Date",
+        "Subject",
+        "Status",
+        "Acknowledged",
+        "Updates",
+        "Impacted Resources"
+    ], title="Upcoming Events")
+    table.align['Subject'] = 'l'
+    table.align['Impacted Resources'] = 'l'
+    for event in events:
+        table.add_row([
+            event.get('id'),
+            utils.clean_time(event.get('startDate')),
+            utils.clean_time(event.get('endDate')),
+            # Some subjects can have \r\n for some reason.
+            utils.clean_splitlines(event.get('subject')),
+            utils.lookup(event, 'statusCode', 'name'),
+            event.get('acknowledgedFlag'),
+            event.get('updateCount'),
+            event.get('impactedResourceCount')
+        ])
+    return table
diff --git a/SoftLayer/CLI/account/invoice_detail.py b/SoftLayer/CLI/account/invoice_detail.py
new file mode 100644
index 00000000..45343184
--- /dev/null
+++ b/SoftLayer/CLI/account/invoice_detail.py
@@ -0,0 +1,61 @@
+"""Invoice details"""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+from SoftLayer.managers.account import AccountManager as AccountManager
+from SoftLayer import utils
+
+
+@click.command()
+@click.argument('identifier')
+@click.option('--details', is_flag=True, default=False, show_default=True,
+              help="Shows a very detailed list of charges")
+@environment.pass_env
+def cli(env, identifier, details):
+    """Invoices and all that mess"""
+
+    manager = AccountManager(env.client)
+    top_items = manager.get_billing_items(identifier)
+
+    title = "Invoice %s" % identifier
+    table = formatting.Table(["Item Id", "Category", "Description", "Single",
+                              "Monthly", "Create Date", "Location"], title=title)
+    table.align['category'] = 'l'
+    table.align['description'] = 'l'
+    for item in top_items:
+        fqdn = "%s.%s" % (item.get('hostName', ''), item.get('domainName', ''))
+        # category id=2046, ram_usage doesn't have a name...
+        category = utils.lookup(item, 'category', 'name') or item.get('categoryCode')
+        description = nice_string(item.get('description'))
+        if fqdn != '.':
+            description = "%s (%s)" % (item.get('description'), fqdn)
+        table.add_row([
+            item.get('id'),
+            category,
+            nice_string(description),
+            "$%.2f" % float(item.get('oneTimeAfterTaxAmount')),
+            "$%.2f" % float(item.get('recurringAfterTaxAmount')),
+            utils.clean_time(item.get('createDate'), out_format="%Y-%m-%d"),
+            utils.lookup(item, 'location', 'name')
+        ])
+        if details:
+            for child in item.get('children', []):
+                table.add_row([
+                    '>>>',
+                    utils.lookup(child, 'category', 'name'),
+                    nice_string(child.get('description')),
+                    "$%.2f" % float(child.get('oneTimeAfterTaxAmount')),
+                    "$%.2f" % float(child.get('recurringAfterTaxAmount')),
+                    '---',
+                    '---'
+                ])
+
+    env.fout(table)
+
+
+def nice_string(ugly_string, limit=100):
+    """Format and trims strings"""
+    return (ugly_string[:limit] + '..') if len(ugly_string) > limit else ugly_string
diff --git a/SoftLayer/CLI/account/invoices.py b/SoftLayer/CLI/account/invoices.py
new file mode 100644
index 00000000..1610ed11
--- /dev/null
+++ b/SoftLayer/CLI/account/invoices.py
@@ -0,0 +1,46 @@
+"""Invoice listing"""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+from SoftLayer.managers.account import AccountManager as AccountManager
+from SoftLayer import utils
+
+
+@click.command()
+@click.option('--limit', default=50, show_default=True,
+              help="How many invoices to get back.")
+@click.option('--closed', is_flag=True, default=False, show_default=True,
+              help="Include invoices with a CLOSED status.")
+@click.option('--all', 'get_all', is_flag=True, default=False, show_default=True,
+              help="Return ALL invoices. There may be a lot of these.")
+@environment.pass_env
+def cli(env, limit, closed=False, get_all=False):
+    """Invoices and all that mess"""
+
+    manager = AccountManager(env.client)
+    invoices = manager.get_invoices(limit, closed, get_all)
+
+    table = formatting.Table([
+        "Id", "Created", "Type", "Status", "Starting Balance", "Ending Balance", "Invoice Amount", "Items"
+    ])
+    table.align['Starting Balance'] = 'l'
+    table.align['Ending Balance'] = 'l'
+    table.align['Invoice Amount'] = 'l'
+    table.align['Items'] = 'l'
+    if isinstance(invoices, dict):
+        invoices = [invoices]
+    for invoice in invoices:
+        table.add_row([
+            invoice.get('id'),
+            utils.clean_time(invoice.get('createDate'), out_format="%Y-%m-%d"),
+            invoice.get('typeCode'),
+            invoice.get('statusCode'),
+            invoice.get('startingBalance'),
+            invoice.get('endingBalance'),
+            invoice.get('invoiceTotalAmount'),
+            invoice.get('itemCount')
+        ])
+    env.fout(table)
diff --git a/SoftLayer/CLI/account/summary.py b/SoftLayer/CLI/account/summary.py
new file mode 100644
index 00000000..f1ae2b6b
--- /dev/null
+++ b/SoftLayer/CLI/account/summary.py
@@ -0,0 +1,39 @@
+"""Account Summary page"""
+# :license: MIT, see LICENSE for more details.
+import click
+
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+from SoftLayer.managers.account import AccountManager as AccountManager
+from SoftLayer import utils
+
+
+@click.command()
+@environment.pass_env
+def cli(env):
+    """Prints some various bits of information about an account"""
+
+    manager = AccountManager(env.client)
+    summary = manager.get_summary()
+    env.fout(get_snapshot_table(summary))
+
+
+def get_snapshot_table(account):
+    """Generates a table for printing account summary data"""
+    table = formatting.KeyValueTable(["Name", "Value"], title="Account Snapshot")
+    table.align['Name'] = 'r'
+    table.align['Value'] = 'l'
+    table.add_row(['Company Name', account.get('companyName', '-')])
+    table.add_row(['Balance', utils.lookup(account, 'pendingInvoice', 'startingBalance')])
+    table.add_row(['Upcoming Invoice', utils.lookup(account, 'pendingInvoice', 'invoiceTotalAmount')])
+    table.add_row(['Image Templates', account.get('blockDeviceTemplateGroupCount', '-')])
+    table.add_row(['Dedicated Hosts', account.get('dedicatedHostCount', '-')])
+    table.add_row(['Hardware', account.get('hardwareCount', '-')])
+    table.add_row(['Virtual Guests', account.get('virtualGuestCount', '-')])
+    table.add_row(['Domains', account.get('domainCount', '-')])
+    table.add_row(['Network Storage Volumes', account.get('networkStorageCount', '-')])
+    table.add_row(['Open Tickets', account.get('openTicketCount', '-')])
+    table.add_row(['Network Vlans', account.get('networkVlanCount', '-')])
+    table.add_row(['Subnets', account.get('subnetCount', '-')])
+    table.add_row(['Users', account.get('userCount', '-')])
+    return table
diff --git a/SoftLayer/CLI/routes.py b/SoftLayer/CLI/routes.py
index b3114408..c04233c5 100644
--- a/SoftLayer/CLI/routes.py
+++ b/SoftLayer/CLI/routes.py
@@ -11,6 +11,13 @@
 
     ('call-api', 'SoftLayer.CLI.call_api:cli'),
 
+    ('account', 'SoftLayer.CLI.account'),
+    ('account:invoice-detail', 'SoftLayer.CLI.account.invoice_detail:cli'),
+    ('account:invoices', 'SoftLayer.CLI.account.invoices:cli'),
+    ('account:events', 'SoftLayer.CLI.account.events:cli'),
+    ('account:event-detail', 'SoftLayer.CLI.account.event_detail:cli'),
+    ('account:summary', 'SoftLayer.CLI.account.summary:cli'),
+
     ('virtual', 'SoftLayer.CLI.virt'),
     ('virtual:cancel', 'SoftLayer.CLI.virt.cancel:cli'),
     ('virtual:capture', 'SoftLayer.CLI.virt.capture:cli'),
diff --git a/SoftLayer/fixtures/SoftLayer_Account.py b/SoftLayer/fixtures/SoftLayer_Account.py
index b4bafac9..bd5a45d0 100644
--- a/SoftLayer/fixtures/SoftLayer_Account.py
+++ b/SoftLayer/fixtures/SoftLayer_Account.py
@@ -662,3 +662,27 @@
         "name": "SPREAD"
     }
 }]
+
+getInvoices = [
+    {
+        'id': 33816665,
+        'modifyDate': '2019-03-04T00:17:42-06:00',
+        'createDate': '2019-03-04T00:17:42-06:00',
+        'startingBalance': '129251.73',
+        'statusCode': 'OPEN',
+        'typeCode': 'RECURRING',
+        'itemCount': 3317,
+        'invoiceTotalAmount': '6230.66'
+    },
+    {
+        'id': 12345667,
+        'modifyDate': '2019-03-05T00:17:42-06:00',
+        'createDate': '2019-03-04T00:17:42-06:00',
+        'startingBalance': '129251.73',
+        'statusCode': 'OPEN',
+        'typeCode': 'RECURRING',
+        'itemCount': 12,
+        'invoiceTotalAmount': '6230.66',
+        'endingBalance': '12345.55'
+    }
+]
diff --git a/SoftLayer/fixtures/SoftLayer_Billing_Invoice.py b/SoftLayer/fixtures/SoftLayer_Billing_Invoice.py
new file mode 100644
index 00000000..d4d89131
--- /dev/null
+++ b/SoftLayer/fixtures/SoftLayer_Billing_Invoice.py
@@ -0,0 +1,23 @@
+getInvoiceTopLevelItems = [
+    {
+        'categoryCode': 'sov_sec_ip_addresses_priv',
+        'createDate': '2018-04-04T23:15:20-06:00',
+        'description': '64 Portable Private IP Addresses',
+        'id': 724951323,
+        'oneTimeAfterTaxAmount': '0',
+        'recurringAfterTaxAmount': '0',
+        'hostName': 'bleg',
+        'domainName': 'beh.com',
+        'category': {'name': 'Private (only) Secondary VLAN IP Addresses'},
+        'children': [
+            {
+                'id': 12345,
+                'category': {'name': 'Fake Child Category'},
+                'description': 'Blah',
+                'oneTimeAfterTaxAmount': 55.50,
+                'recurringAfterTaxAmount': 0.10
+            }
+        ],
+        'location': {'name': 'fra02'}
+    }
+]
diff --git a/SoftLayer/fixtures/SoftLayer_Notification_Occurrence_Event.py b/SoftLayer/fixtures/SoftLayer_Notification_Occurrence_Event.py
new file mode 100644
index 00000000..7c674043
--- /dev/null
+++ b/SoftLayer/fixtures/SoftLayer_Notification_Occurrence_Event.py
@@ -0,0 +1,31 @@
+getObject = {
+    'endDate': '2019-03-18T17:00:00-06:00',
+    'id': 1234,
+    'lastImpactedUserCount': 417756,
+    'modifyDate': '2019-03-12T15:32:48-06:00',
+    'recoveryTime': None,
+    'startDate': '2019-03-18T16:00:00-06:00',
+    'subject': 'Public Website Maintenance',
+    'summary': 'Blah Blah Blah',
+    'systemTicketId': 76057381,
+    'acknowledgedFlag': False,
+    'attachments': [],
+    'impactedResources': [{
+        'resourceType': 'Server',
+        'resourceTableId': 12345,
+        'hostname': 'test',
+        'privateIp': '10.0.0.1',
+        'filterLable': 'Server'
+    }],
+    'notificationOccurrenceEventType': {'keyName': 'PLANNED'},
+    'statusCode': {'keyName': 'PUBLISHED', 'name': 'Published'},
+    'updates': [{
+        'contents': 'More Blah Blah',
+        'createDate': '2019-03-12T13:07:22-06:00',
+        'endDate': None, 'startDate': '2019-03-12T13:07:22-06:00'
+    }]
+}
+
+getAllObjects = [getObject]
+
+acknowledgeNotification = True
diff --git a/SoftLayer/managers/account.py b/SoftLayer/managers/account.py
new file mode 100644
index 00000000..1f7d4871
--- /dev/null
+++ b/SoftLayer/managers/account.py
@@ -0,0 +1,141 @@
+"""
+    SoftLayer.account
+    ~~~~~~~~~~~~~~~~~~~~~~~
+    Account manager
+
+    :license: MIT, see License for more details.
+"""
+
+import logging
+
+from SoftLayer import utils
+
+# Invalid names are ignored due to long method names and short argument names
+# pylint: disable=invalid-name, no-self-use
+
+LOGGER = logging.getLogger(__name__)
+
+
+class AccountManager(utils.IdentifierMixin, object):
+    """Common functions for getting information from the Account service
+
+    :param SoftLayer.API.BaseClient client: the client instance
+    """
+
+    def __init__(self, client):
+        self.client = client
+
+    def get_summary(self):
+        """Gets some basic account information
+
+        :return: Account object
+        """
+        mask = """mask[
+            nextInvoiceTotalAmount,
+            pendingInvoice[invoiceTotalAmount],
+            blockDeviceTemplateGroupCount,
+            dedicatedHostCount,
+            domainCount,
+            hardwareCount,
+            networkStorageCount,
+            openTicketCount,
+            networkVlanCount,
+            subnetCount,
+            userCount,
+            virtualGuestCount
+            ]
+        """
+        return self.client.call('Account', 'getObject', mask=mask)
+
+    def get_upcoming_events(self):
+        """Retreives a list of Notification_Occurrence_Events that have not ended yet
+
+        :return: SoftLayer_Notification_Occurrence_Event
+        """
+        mask = "mask[id, subject, startDate, endDate, statusCode, acknowledgedFlag, impactedResourceCount, updateCount]"
+        _filter = {
+            'endDate': {
+                'operation': '> sysdate'
+            },
+            'startDate': {
+                'operation': 'orderBy',
+                'options': [{
+                    'name': 'sort',
+                    'value': ['ASC']
+                }]
+            }
+        }
+        return self.client.call('Notification_Occurrence_Event', 'getAllObjects', filter=_filter, mask=mask, iter=True)
+
+    def ack_event(self, event_id):
+        """Acknowledge an event. This mostly prevents it from appearing as a notification in the control portal.
+
+        :param int event_id: Notification_Occurrence_Event ID you want to ack
+        :return: True on success, Exception otherwise.
+        """
+        return self.client.call('Notification_Occurrence_Event', 'acknowledgeNotification', id=event_id)
+
+    def get_event(self, event_id):
+        """Gets details about a maintenance event
+
+        :param int event_id: Notification_Occurrence_Event ID
+        :return: Notification_Occurrence_Event
+        """
+        mask = """mask[
+            acknowledgedFlag,
+            attachments,
+            impactedResources,
+            statusCode,
+            updates,
+            notificationOccurrenceEventType]
+        """
+        return self.client.call('Notification_Occurrence_Event', 'getObject', id=event_id, mask=mask)
+
+    def get_invoices(self, limit=50, closed=False, get_all=False):
+        """Gets an accounts invoices.
+
+        :param int limit: Number of invoices to get back in a single call.
+        :param bool closed: If True, will also get CLOSED invoices
+        :param bool get_all: If True, will paginate through invoices until all have been retrieved.
+        :return: Billing_Invoice
+        """
+        mask = "mask[invoiceTotalAmount, itemCount]"
+        _filter = {
+            'invoices': {
+                'createDate': {
+                    'operation': 'orderBy',
+                    'options': [{
+                        'name': 'sort',
+                        'value': ['DESC']
+                    }]
+                },
+                'statusCode': {'operation': 'OPEN'},
+            }
+        }
+        if closed:
+            del _filter['invoices']['statusCode']
+
+        return self.client.call('Account', 'getInvoices', mask=mask, filter=_filter, iter=get_all, limit=limit)
+
+    def get_billing_items(self, identifier):
+        """Gets all topLevelBillingItems from a specific invoice
+
+        :param int identifier: Invoice Id
+        :return: Billing_Invoice_Item
+        """
+
+        mask = """mask[
+            id, description, hostName, domainName, oneTimeAfterTaxAmount, recurringAfterTaxAmount, createDate,
+            categoryCode,
+            category[name],
+            location[name],
+            children[id, category[name], description, oneTimeAfterTaxAmount, recurringAfterTaxAmount]
+        ]"""
+        return self.client.call(
+            'Billing_Invoice',
+            'getInvoiceTopLevelItems',
+            id=identifier,
+            mask=mask,
+            iter=True,
+            limit=100
+        )
diff --git a/SoftLayer/utils.py b/SoftLayer/utils.py
index 5c68f7c4..f4904adf 100644
--- a/SoftLayer/utils.py
+++ b/SoftLayer/utils.py
@@ -142,9 +142,9 @@ def format_event_log_date(date_string, utc):
         utc = "+0000"
 
     iso_time_zone = utc[:3] + ':' + utc[3:]
-    clean_time = "{}.000000{}".format(dirty_time, iso_time_zone)
+    cleaned_time = "{}.000000{}".format(dirty_time, iso_time_zone)
 
-    return clean_time
+    return cleaned_time
 
 
 def event_log_filter_between_date(start, end, utc):
@@ -288,3 +288,26 @@ def clean_string(string):
         return ''
     else:
         return " ".join(string.split())
+
+
+def clean_splitlines(string):
+    """Returns a string where \r\n is replaced with \n"""
+    if string is None:
+        return ''
+    else:
+        return "\n".join(string.splitlines())
+
+
+def clean_time(sltime, in_format='%Y-%m-%dT%H:%M:%S%z', out_format='%Y-%m-%d %H:%M'):
+    """Easy way to format time strings
+
+    :param string sltime: A softlayer formatted time string
+    :param string in_format: Datetime format for strptime
+    :param string out_format: Datetime format for strftime
+    """
+    try:
+        clean = datetime.datetime.strptime(sltime, in_format)
+        return clean.strftime(out_format)
+    # The %z option only exists with py3.6+
+    except ValueError:
+        return sltime
diff --git a/docs/api/managers/account.rst b/docs/api/managers/account.rst
new file mode 100644
index 00000000..25d76ed6
--- /dev/null
+++ b/docs/api/managers/account.rst
@@ -0,0 +1,5 @@
+.. _account:
+
+.. automodule:: SoftLayer.managers.account
+   :members:
+   :inherited-members:
\ No newline at end of file
diff --git a/docs/cli.rst b/docs/cli.rst
index 6d8b1821..709741aa 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -12,12 +12,12 @@ functionality not fully documented here.
 .. toctree::
    :maxdepth: 2
 
-   cli/ipsec
+   cli/account
    cli/vs
    cli/hardware
    cli/ordering
    cli/users
-
+   cli/ipsec
 
 .. _config_setup:
 
diff --git a/docs/cli/account.rst b/docs/cli/account.rst
new file mode 100644
index 00000000..9b3ad695
--- /dev/null
+++ b/docs/cli/account.rst
@@ -0,0 +1,25 @@
+.. _cli_account:
+
+Account Commands
+=================
+
+
+.. click:: SoftLayer.CLI.account.summary:cli
+    :prog: account summary
+    :show-nested:
+
+.. click:: SoftLayer.CLI.account.events:cli
+    :prog: account events
+    :show-nested:
+
+.. click:: SoftLayer.CLI.account.event_detail:cli
+    :prog: account event-detail
+    :show-nested:
+
+.. click:: SoftLayer.CLI.account.invoices:cli
+    :prog: account invoices
+    :show-nested:
+
+.. click:: SoftLayer.CLI.account.invoice_detail:cli
+    :prog: account invoice-detail
+    :show-nested:
\ No newline at end of file


Test patch:
diff --git a/tests/CLI/modules/account_tests.py b/tests/CLI/modules/account_tests.py
new file mode 100644
index 00000000..c495546c
--- /dev/null
+++ b/tests/CLI/modules/account_tests.py
@@ -0,0 +1,95 @@
+"""
+    SoftLayer.tests.CLI.modules.account_tests
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    Tests for the user cli command
+"""
+from SoftLayer.fixtures import SoftLayer_Account as SoftLayer_Account
+from SoftLayer import testing
+
+
+class AccountCLITests(testing.TestCase):
+
+    def set_up(self):
+        self.SLNOE = 'SoftLayer_Notification_Occurrence_Event'
+
+    # slcli account event-detail
+    def test_event_detail(self):
+        result = self.run_command(['account', 'event-detail', '1234'])
+        self.assert_no_fail(result)
+        self.assert_called_with(self.SLNOE, 'getObject', identifier='1234')
+
+    def test_event_details_ack(self):
+        result = self.run_command(['account', 'event-detail', '1234', '--ack'])
+        self.assert_no_fail(result)
+        self.assert_called_with(self.SLNOE, 'getObject', identifier='1234')
+        self.assert_called_with(self.SLNOE, 'acknowledgeNotification', identifier='1234')
+
+    # slcli account events
+    def test_events(self):
+        result = self.run_command(['account', 'events'])
+        self.assert_no_fail(result)
+        self.assert_called_with(self.SLNOE, 'getAllObjects')
+
+    def test_event_ack_all(self):
+        result = self.run_command(['account', 'events', '--ack-all'])
+        self.assert_no_fail(result)
+        self.assert_called_with(self.SLNOE, 'getAllObjects')
+        self.assert_called_with(self.SLNOE, 'acknowledgeNotification', identifier=1234)
+
+    # slcli account invoice-detail
+
+    def test_invoice_detail(self):
+        result = self.run_command(['account', 'invoice-detail', '1234'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Billing_Invoice', 'getInvoiceTopLevelItems', identifier='1234')
+
+    def test_invoice_detail_details(self):
+        result = self.run_command(['account', 'invoice-detail', '1234', '--details'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Billing_Invoice', 'getInvoiceTopLevelItems', identifier='1234')
+
+    # slcli account invoices
+    def test_invoices(self):
+        result = self.run_command(['account', 'invoices'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Account', 'getInvoices', limit=50)
+
+    def test_invoices_limited(self):
+        result = self.run_command(['account', 'invoices', '--limit=10'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Account', 'getInvoices', limit=10)
+
+    def test_invoices_closed(self):
+        _filter = {
+            'invoices': {
+                'createDate': {
+                    'operation': 'orderBy',
+                    'options': [{
+                        'name': 'sort',
+                        'value': ['DESC']
+                    }]
+                }
+            }
+        }
+        result = self.run_command(['account', 'invoices', '--closed'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Account', 'getInvoices', limit=50, filter=_filter)
+
+    def test_invoices_all(self):
+        result = self.run_command(['account', 'invoices', '--all'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Account', 'getInvoices', limit=50)
+
+    def test_single_invoice(self):
+        amock = self.set_mock('SoftLayer_Account', 'getInvoices')
+        amock.return_value = SoftLayer_Account.getInvoices[0]
+        result = self.run_command(['account', 'invoices', '--limit=1'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Account', 'getInvoices', limit=1)
+
+    # slcli account summary
+    def test_account_summary(self):
+        result = self.run_command(['account', 'summary'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Account', 'getObject')
diff --git a/tests/CLI/modules/event_log_tests.py b/tests/CLI/modules/event_log_tests.py
index a7ff0dcb..b36ff853 100644
--- a/tests/CLI/modules/event_log_tests.py
+++ b/tests/CLI/modules/event_log_tests.py
@@ -36,7 +36,7 @@ def test_get_event_log_with_metadata(self):
                         '"portRangeMax":2001,"portRangeMin":2000,"protocol":"tcp",'
                         '"remoteGroupId":null,"remoteIp":null,"ruleId":"100"}],"securityGroupId":"200",'
                         '"securityGroupName":"test_SG"}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
@@ -53,7 +53,7 @@ def test_get_event_log_with_metadata(self):
                         '"requestId":"96c9b47b9e102d2e1d81fba",'
                         '"securityGroupId":"200",'
                         '"securityGroupName":"test_SG"}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
@@ -70,7 +70,7 @@ def test_get_event_log_with_metadata(self):
                         '"ethertype":"IPv4",'
                         '"portRangeMax":2001,"portRangeMin":2000,"protocol":"tcp",'
                         '"remoteGroupId":null,"remoteIp":null,"ruleId":"800"}]}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
@@ -86,7 +86,7 @@ def test_get_event_log_with_metadata(self):
                         '"networkComponentId":"100",'
                         '"networkInterfaceType":"public",'
                         '"requestId":"6b9a87a9ab8ac9a22e87a00"}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
@@ -103,7 +103,7 @@ def test_get_event_log_with_metadata(self):
                         '"ethertype":"IPv4",'
                         '"portRangeMax":2001,"portRangeMin":2000,"protocol":"tcp",'
                         '"remoteGroupId":null,"remoteIp":null,"ruleId":"800"}]}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
@@ -119,7 +119,7 @@ def test_get_event_log_with_metadata(self):
                         '"networkComponentId":"100",'
                         '"networkInterfaceType":"public",'
                         '"requestId":"4709e02ad42c83f80345904"}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
@@ -216,7 +216,7 @@ def test_get_event_table(self):
                         '"portRangeMax":2001,"portRangeMin":2000,"protocol":"tcp",'
                         '"remoteGroupId":null,"remoteIp":null,"ruleId":"100"}],"securityGroupId":"200",'
                         '"securityGroupName":"test_SG"}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
@@ -233,7 +233,7 @@ def test_get_event_table(self):
                         '"requestId":"96c9b47b9e102d2e1d81fba",'
                         '"securityGroupId":"200",'
                         '"securityGroupName":"test_SG"}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
@@ -250,7 +250,7 @@ def test_get_event_table(self):
                         '"ethertype":"IPv4",'
                         '"portRangeMax":2001,"portRangeMin":2000,"protocol":"tcp",'
                         '"remoteGroupId":null,"remoteIp":null,"ruleId":"800"}]}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
@@ -266,7 +266,7 @@ def test_get_event_table(self):
                         '"networkComponentId":"100",'
                         '"networkInterfaceType":"public",'
                         '"requestId":"6b9a87a9ab8ac9a22e87a00"}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
@@ -283,7 +283,7 @@ def test_get_event_table(self):
                         '"ethertype":"IPv4",'
                         '"portRangeMax":2001,"portRangeMin":2000,"protocol":"tcp",'
                         '"remoteGroupId":null,"remoteIp":null,"ruleId":"800"}]}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
@@ -299,7 +299,7 @@ def test_get_event_table(self):
                         '"networkComponentId":"100",'
                         '"networkInterfaceType":"public",'
                         '"requestId":"4709e02ad42c83f80345904"}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
@@ -308,7 +308,7 @@ def test_get_event_table(self):
 
         for log in expected:
             table_fix.add_row([log['event'], log['object'], log['type'], log['date'],
-                              log['username'], log['metadata'].strip("{}\n\t")])
+                               log['username'], log['metadata'].strip("{}\n\t")])
         expected_output = formatting.format_output(table_fix) + '\n'
 
         result = self.run_command(args=['event-log', 'get', '--metadata'], fmt='table')
diff --git a/tests/CLI/modules/securitygroup_tests.py b/tests/CLI/modules/securitygroup_tests.py
index 4ce0cd56..b6801fcc 100644
--- a/tests/CLI/modules/securitygroup_tests.py
+++ b/tests/CLI/modules/securitygroup_tests.py
@@ -310,7 +310,7 @@ def test_securitygroup_get_by_request_id(self, event_mock):
                         '"requestId": "96c9b47b9e102d2e1d81fba",'
                         '"securityGroupId": "200",'
                         '"securityGroupName": "test_SG"}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
@@ -329,7 +329,7 @@ def test_securitygroup_get_by_request_id(self, event_mock):
                         '"remoteGroupId": null,'
                         '"remoteIp": null,'
                         '"ruleId": "800"}]}'
-                    ),
+                ),
                     indent=4,
                     sort_keys=True
                 )
diff --git a/tests/CLI/modules/vs/vs_create_tests.py b/tests/CLI/modules/vs/vs_create_tests.py
index 5075d225..91946471 100644
--- a/tests/CLI/modules/vs/vs_create_tests.py
+++ b/tests/CLI/modules/vs/vs_create_tests.py
@@ -604,12 +604,12 @@ def test_create_with_userdata(self, confirm_mock):
                                    '--userdata', 'This is my user data ok'])
         self.assert_no_fail(result)
         expected_guest = [
-                {
-                    'domain': 'test.local',
-                    'hostname': 'test',
-                    'userData': [{'value': 'This is my user data ok'}]
-                }
-            ]
+            {
+                'domain': 'test.local',
+                'hostname': 'test',
+                'userData': [{'value': 'This is my user data ok'}]
+            }
+        ]
         # Returns a list of API calls that hit SL_Product_Order::placeOrder
         api_call = self.calls('SoftLayer_Product_Order', 'placeOrder')
         # Doing this because the placeOrder args are huge and mostly not needed to test
diff --git a/tests/managers/account_tests.py b/tests/managers/account_tests.py
new file mode 100644
index 00000000..7efc42ac
--- /dev/null
+++ b/tests/managers/account_tests.py
@@ -0,0 +1,54 @@
+"""
+    SoftLayer.tests.managers.account_tests
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+"""
+
+from SoftLayer.managers.account import AccountManager as AccountManager
+from SoftLayer import testing
+
+
+class AccountManagerTests(testing.TestCase):
+
+    def set_up(self):
+        self.manager = AccountManager(self.client)
+        self.SLNOE = 'SoftLayer_Notification_Occurrence_Event'
+
+    def test_get_summary(self):
+        self.manager.get_summary()
+        self.assert_called_with('SoftLayer_Account', 'getObject')
+
+    def test_get_upcoming_events(self):
+        self.manager.get_upcoming_events()
+        self.assert_called_with(self.SLNOE, 'getAllObjects')
+
+    def test_ack_event(self):
+        self.manager.ack_event(12345)
+        self.assert_called_with(self.SLNOE, 'acknowledgeNotification', identifier=12345)
+
+    def test_get_event(self):
+        self.manager.get_event(12345)
+        self.assert_called_with(self.SLNOE, 'getObject', identifier=12345)
+
+    def test_get_invoices(self):
+        self.manager.get_invoices()
+        self.assert_called_with('SoftLayer_Account', 'getInvoices')
+
+    def test_get_invoices_closed(self):
+        self.manager.get_invoices(closed=True)
+        _filter = {
+            'invoices': {
+                'createDate': {
+                    'operation': 'orderBy',
+                    'options': [{
+                        'name': 'sort',
+                        'value': ['DESC']
+                    }]
+                }
+            }
+        }
+        self.assert_called_with('SoftLayer_Account', 'getInvoices', filter=_filter)
+
+    def test_get_billing_items(self):
+        self.manager.get_billing_items(12345)
+        self.assert_called_with('SoftLayer_Billing_Invoice', 'getInvoiceTopLevelItems')