Repository: softlayer/softlayer-python
Pull request #1254 (2020-04-06 22:18:06)

added account billing-items/item-details/cancel-item commands
- slcli account billing-items
- slcli account item-detail
- slcli account cancel-item

Instead of doing a report, just basically reproduced https://cloud.ibm.com/billing/billing-items
with item-detail and cancel-item commands as needed.


Patch:
diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml
new file mode 100644
index 00000000..424eb7c2
--- /dev/null
+++ b/.github/workflows/documentation.yml
@@ -0,0 +1,27 @@
+name: softlayer
+
+on:
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: [3.8]
+
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v1
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install -r tools/test-requirements.txt
+    - name: Documentation Checks
+      run: |
+        python docCheck.py
diff --git a/SoftLayer/CLI/account/billing_items.py b/SoftLayer/CLI/account/billing_items.py
new file mode 100644
index 00000000..32bc6c27
--- /dev/null
+++ b/SoftLayer/CLI/account/billing_items.py
@@ -0,0 +1,60 @@
+"""Lists all active billing items on this account. See https://cloud.ibm.com/billing/billing-items"""
+# :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):
+    """Lists billing items with some other useful information.
+
+    Similiar to https://cloud.ibm.com/billing/billing-items
+    """
+
+    manager = AccountManager(env.client)
+    items = manager.get_account_billing_items()
+    table = item_table(items)
+
+    env.fout(table)
+
+
+def item_table(items):
+    """Formats a table for billing items"""
+    table = formatting.Table([
+        "Id",
+        "Create Date",
+        "Cost",
+        "Category Code",
+        "Ordered By",
+        "Description",
+        "Notes"
+    ], title="Billing Items")
+    table.align['Description'] = 'l'
+    table.align['Category Code'] = 'l'
+    for item in items:
+        description = item.get('description')
+        fqdn = "{}.{}".format(item.get('hostName', ''), item.get('domainName', ''))
+        if fqdn != ".":
+            description = fqdn
+        user = utils.lookup(item, 'orderItem', 'order', 'userRecord')
+        ordered_by = "IBM"
+        create_date = utils.clean_time(item.get('createDate'), in_format='%Y-%m-%d', out_format='%Y-%m-%d')
+        if user:
+            # ordered_by = "{} ({})".format(user.get('displayName'), utils.lookup(user, 'userStatus', 'name'))
+            ordered_by = user.get('displayName')
+
+        table.add_row([
+            item.get('id'),
+            create_date,
+            item.get('nextInvoiceTotalRecurringAmount'),
+            item.get('categoryCode'),
+            ordered_by,
+            utils.trim_to(description, 50),
+            utils.trim_to(item.get('notes', 'None'), 40),
+        ])
+    return table
diff --git a/SoftLayer/CLI/account/cancel_item.py b/SoftLayer/CLI/account/cancel_item.py
new file mode 100644
index 00000000..de0fa446
--- /dev/null
+++ b/SoftLayer/CLI/account/cancel_item.py
@@ -0,0 +1,18 @@
+"""Cancels a billing item."""
+# :license: MIT, see LICENSE for more details.
+import click
+
+from SoftLayer.CLI import environment
+from SoftLayer.managers.account import AccountManager as AccountManager
+
+
+@click.command()
+@click.argument('identifier')
+@environment.pass_env
+def cli(env, identifier):
+    """Cancels a billing item."""
+
+    manager = AccountManager(env.client)
+    item = manager.cancel_item(identifier)
+
+    env.fout(item)
diff --git a/SoftLayer/CLI/account/item_detail.py b/SoftLayer/CLI/account/item_detail.py
new file mode 100644
index 00000000..7a2c53df
--- /dev/null
+++ b/SoftLayer/CLI/account/item_detail.py
@@ -0,0 +1,52 @@
+"""Gets some details about a specific billing item."""
+# :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')
+@environment.pass_env
+def cli(env, identifier):
+    """Gets detailed information about a billing item."""
+    manager = AccountManager(env.client)
+    item = manager.get_billing_item(identifier)
+    env.fout(item_table(item))
+
+
+def item_table(item):
+    """Formats a table for billing items"""
+
+    date_format = '%Y-%m-%d'
+    table = formatting.KeyValueTable(["Key", "Value"], title="{}".format(item.get('description', 'Billing Item')))
+    table.add_row(['createDate', utils.clean_time(item.get('createDate'), date_format, date_format)])
+    table.add_row(['cycleStartDate', utils.clean_time(item.get('cycleStartDate'), date_format, date_format)])
+    table.add_row(['cancellationDate', utils.clean_time(item.get('cancellationDate'), date_format, date_format)])
+    table.add_row(['description', item.get('description')])
+    fqdn = "{}.{}".format(item.get('hostName'), item.get('domain'))
+    if fqdn != ".":
+        table.add_row(['FQDN', fqdn])
+
+    if item.get('hourlyFlag', False):
+        table.add_row(['hourlyRecurringFee', item.get('hourlyRecurringFee')])
+        table.add_row(['hoursUsed', item.get('hoursUsed')])
+        table.add_row(['currentHourlyCharge', item.get('currentHourlyCharge')])
+    else:
+        table.add_row(['recurringFee', item.get('recurringFee')])
+
+    ordered_by = "IBM"
+    user = utils.lookup(item, 'orderItem', 'order', 'userRecord')
+    if user:
+        ordered_by = "{} ({})".format(user.get('displayName'), utils.lookup(user, 'userStatus', 'name'))
+    table.add_row(['Ordered By', ordered_by])
+    table.add_row(['Notes', item.get('notes')])
+    table.add_row(['Location', utils.lookup(item, 'location', 'name')])
+    if item.get('children'):
+        for child in item.get('children'):
+            table.add_row([child.get('categoryCode'), child.get('description')])
+
+    return table
diff --git a/SoftLayer/CLI/routes.py b/SoftLayer/CLI/routes.py
index f6edee47..b8fd294a 100644
--- a/SoftLayer/CLI/routes.py
+++ b/SoftLayer/CLI/routes.py
@@ -17,6 +17,9 @@
     ('account:events', 'SoftLayer.CLI.account.events:cli'),
     ('account:event-detail', 'SoftLayer.CLI.account.event_detail:cli'),
     ('account:summary', 'SoftLayer.CLI.account.summary:cli'),
+    ('account:billing-items', 'SoftLayer.CLI.account.billing_items:cli'),
+    ('account:item-detail', 'SoftLayer.CLI.account.item_detail:cli'),
+    ('account:cancel-item', 'SoftLayer.CLI.account.cancel_item:cli'),
 
     ('virtual', 'SoftLayer.CLI.virt'),
     ('virtual:bandwidth', 'SoftLayer.CLI.virt.bandwidth:cli'),
diff --git a/SoftLayer/fixtures/SoftLayer_Account.py b/SoftLayer/fixtures/SoftLayer_Account.py
index ddb2a435..9524c61c 100644
--- a/SoftLayer/fixtures/SoftLayer_Account.py
+++ b/SoftLayer/fixtures/SoftLayer_Account.py
@@ -417,7 +417,7 @@
 getClosedTickets = [ticket for ticket in getTickets
                     if ticket['statusId'] == 1002]
 
-getCurrentUser = {'id': 12345,
+getCurrentUser = {'id': 12345, 'username': 'testAccount',
                   'apiAuthenticationKeys': [{'authenticationKey': 'A' * 64}]}
 
 getCdnAccounts = [
@@ -852,3 +852,91 @@
         }
     }
 ]
+
+getAllTopLevelBillingItems = [
+    {
+        "allowCancellationFlag": 1,
+        "cancellationDate": "None",
+        "categoryCode": "server",
+        "createDate": "2015-05-28T09:53:41-06:00",
+        "cycleStartDate": "2020-04-03T23:12:04-06:00",
+        "description": "Dual E5-2690 v3 (12 Cores, 2.60 GHz)",
+        "domainName": "sl-netbase.com",
+        "hostName": "testsangeles101",
+        "id": 53891943,
+        "lastBillDate": "2020-04-03T23:12:04-06:00",
+        "modifyDate": "2020-04-03T23:12:07-06:00",
+        "nextBillDate": "2020-05-03T23:00:00-06:00",
+        "orderItemId": 68626055,
+        "parentId": "None",
+        "recurringFee": "1000",
+        "recurringFeeTaxRate": "0",
+        "recurringMonths": 1,
+        "hourlyFlag": False,
+        "location": {
+            "id": 265592,
+            "longName": "Amsterdam 1",
+            "name": "ams01",
+            "statusId": 2
+        },
+        "nextInvoiceTotalRecurringAmount": 0,
+        "orderItem": {
+            "id": 68626055,
+            "order": {
+                "id": 4544893,
+                "userRecord": {
+                    "displayName": "TEst",
+                    "email": "test@us.ibm.com",
+                    "id": 167758,
+                    "userStatus": {
+                        "id": 1001,
+                        "keyName": "CANCEL_PENDING",
+                        "name": "Cancel Pending"
+                    }
+                }
+            }
+        },
+        "resourceTableId": 544444
+    },
+    {
+        "allowCancellationFlag": 1,
+        "cancellationDate": "None",
+        "categoryCode": "server",
+        "createDate": "2015-05-28T09:56:44-06:00",
+        "cycleStartDate": "2020-04-03T23:12:05-06:00",
+        "description": "Dual E5-2690 v3 (12 Cores, 2.60 GHz)",
+        "domainName": "sl-netbase.com",
+        "hostName": "testsangeles101",
+        "id": 53892197,
+        "lastBillDate": "2020-04-03T23:12:05-06:00",
+        "modifyDate": "2020-04-03T23:12:07-06:00",
+        "nextBillDate": "2020-05-03T23:00:00-06:00",
+        "orderItemId": 68626801,
+        "recurringFee": "22220",
+        "hourlyFlag": False,
+        "location": {
+            "id": 265592,
+            "longName": "Amsterdam 1",
+            "name": "ams01",
+            "statusId": 2
+        },
+        "nextInvoiceTotalRecurringAmount": 0,
+        "orderItem": {
+            "id": 68626801,
+            "order": {
+                "id": 4545911,
+                "userRecord": {
+                    "displayName": "Test",
+                    "email": "test@us.ibm.com",
+                    "id": 167758,
+                    "userStatus": {
+                        "id": 1001,
+                        "keyName": "ACTIVE",
+                        "name": "Active"
+                    }
+                }
+            }
+        },
+        "resourceTableId": 777777
+    }
+]
diff --git a/SoftLayer/fixtures/SoftLayer_Billing_Item.py b/SoftLayer/fixtures/SoftLayer_Billing_Item.py
index 6bcf8449..a35e51c6 100644
--- a/SoftLayer/fixtures/SoftLayer_Billing_Item.py
+++ b/SoftLayer/fixtures/SoftLayer_Billing_Item.py
@@ -1,3 +1,67 @@
 cancelService = True
 cancelServiceOnAnniversaryDate = True
 cancelItem = True
+
+getObject = {
+    "allowCancellationFlag": 1,
+    "cancellationDate": "None",
+    "categoryCode": "server",
+    "createDate": "2015-05-28T10:36:38-06:00",
+    "cycleStartDate": "2020-04-03T23:12:05-06:00",
+    "description": "Dual E5-2690 v3 (12 Cores, 2.60 GHz)",
+    "domainName": "sl-test.com",
+    "hostName": "testsangeles101",
+    "id": 53897671,
+    "lastBillDate": "2020-04-03T23:12:05-06:00",
+    "modifyDate": "2020-04-03T23:12:07-06:00",
+    "nextBillDate": "2020-05-03T23:00:00-06:00",
+    "orderItemId": 68634907,
+    "parentId": "None",
+    "recurringFee": "1000",
+    "recurringMonths": 1,
+    "children": [
+        {
+            "allowCancellationFlag": 1,
+            "associatedBillingItemId": "53897671",
+            "cancellationDate": "None",
+            "categoryCode": "second_processor",
+            "createDate": "2015-05-28T10:36:38-06:00",
+            "cycleStartDate": "2020-04-03T23:12:05-06:00",
+            "description": "E5-2690 v3 (12 Cores, 2.60 GHz)",
+            "id": 53897673,
+            "lastBillDate": "2020-04-03T23:12:05-06:00",
+            "modifyDate": "2020-04-03T23:12:07-06:00",
+            "nextBillDate": "2020-05-03T23:00:00-06:00",
+            "oneTimeFee": "0",
+            "oneTimeFeeTaxRate": "0",
+            "orderItemId": 68634909,
+            "parentId": 53897671,
+            "recurringFee": "1000",
+            "setupFeeTaxRate": "0"
+        },
+    ],
+    "hourlyFlag": False,
+    "location": {
+        "id": 265592,
+        "longName": "Amsterdam 1",
+        "name": "ams01",
+        "statusId": 2
+    },
+    "nextInvoiceTotalRecurringAmount": 0,
+    "orderItem": {
+        "id": 68634907,
+        "order": {
+            "id": 4546175,
+            "userRecord": {
+                "displayName": "Tester",
+                "email": "test@us.ibm.com",
+                "id": 167758,
+                "userStatus": {
+                    "keyName": "ACTIVE",
+                    "name": "Active"
+                }
+            }
+        }
+    },
+    "resourceTableId": "None"
+}
diff --git a/SoftLayer/managers/account.py b/SoftLayer/managers/account.py
index 1f7d4871..4269bc7a 100644
--- a/SoftLayer/managers/account.py
+++ b/SoftLayer/managers/account.py
@@ -139,3 +139,61 @@ def get_billing_items(self, identifier):
             iter=True,
             limit=100
         )
+
+    def get_account_billing_items(self, mask=None):
+        """Gets all the topLevelBillingItems currently active on the account
+
+        :param string mask: Object Mask
+        :return: Billing_Item
+        """
+
+        if mask is None:
+            mask = """mask[
+                orderItem[id,order[id,userRecord[id,email,displayName,userStatus]]],
+                nextInvoiceTotalRecurringAmount,
+                location, hourlyFlag
+            ]"""
+
+        object_filter = {
+            "allTopLevelBillingItems": {
+                "cancellationDate": {
+                    "operation": "is null"
+                },
+                "createDate": utils.query_filter_orderby()
+            }
+        }
+
+        return self.client.call('Account', 'getAllTopLevelBillingItems',
+                                mask=mask, filter=object_filter, iter=True, limit=100)
+
+    def get_billing_item(self, identifier, mask=None):
+        """Gets details about a billing item
+
+        :param int identifier Billing_Item id
+        :param string mask: Object mask to use.
+        :return: Billing_Item
+        """
+
+        if mask is None:
+            mask = """mask[
+                orderItem[id,order[id,userRecord[id,email,displayName,userStatus]]],
+                nextInvoiceTotalRecurringAmount,
+                location, hourlyFlag, children
+            ]"""
+
+        return self.client.call('Billing_Item', 'getObject', id=identifier, mask=mask)
+
+    def cancel_item(self, identifier, reason="No longer needed", note=None):
+        """Cancels a specific billing item with a reason
+
+        :param int identifier: Billing_Item id
+        :param string reason: A cancellation reason
+        :param string note: Custom note to set when cancelling. Defaults to information about who canceled the item.
+        :return: bool
+        """
+
+        if note is None:
+            user = self.client.call('Account', 'getCurrentUser', mask="mask[id,displayName,email,username]")
+            note = "Cancelled by {} with the SLCLI".format(user.get('username'))
+
+        return self.client.call('Billing_Item', 'cancelItem', False, True, reason, note, id=identifier)
diff --git a/SoftLayer/utils.py b/SoftLayer/utils.py
index 0234bf72..cc6d7bd4 100644
--- a/SoftLayer/utils.py
+++ b/SoftLayer/utils.py
@@ -121,6 +121,21 @@ def query_filter_date(start, end):
     }
 
 
+def query_filter_orderby(sort="ASC"):
+    """Returns an object filter operation for sorting
+
+    :param string sort: either ASC or DESC
+    """
+    _filter = {
+        "operation": "orderBy",
+        "options": [{
+            "name": "sort",
+            "value": [sort]
+        }]
+    }
+    return _filter
+
+
 def format_event_log_date(date_string, utc):
     """Gets a date in the format that the SoftLayer_EventLog object likes.
 
@@ -305,7 +320,12 @@ def clean_time(sltime, in_format='%Y-%m-%dT%H:%M:%S%z', out_format='%Y-%m-%d %H:
         clean = datetime.datetime.strptime(sltime, in_format)
         return clean.strftime(out_format)
     # The %z option only exists with py3.6+
-    except ValueError:
+    except ValueError as e:
+        # Just ignore data that in_format didn't process.
+        ulr = len(e.args[0].partition('unconverted data remains: ')[2])
+        if ulr:
+            clean = datetime.datetime.strptime(sltime[:-ulr], in_format)
+            return clean.strftime(out_format)
         return sltime
 
 
@@ -334,3 +354,17 @@ def days_to_datetime(days):
         date -= datetime.timedelta(days=days)
 
     return date
+
+
+def trim_to(string, length=80, tail="..."):
+    """Returns a string that is length long. tail added if trimmed
+
+    :param string string: String you want to trim
+    :param int length: max length for the string
+    :param string tail: appended to strings that were trimmed.
+    """
+
+    if len(string) > length:
+        return string[:length] + tail
+    else:
+        return string
diff --git a/docCheck.py b/docCheck.py
new file mode 100644
index 00000000..f6b11ba3
--- /dev/null
+++ b/docCheck.py
@@ -0,0 +1,94 @@
+"""Makes sure all routes have documentation"""
+import SoftLayer
+from SoftLayer.CLI import routes
+from pprint import pprint as pp
+import glob
+import logging
+import os
+import sys
+import re
+
+class Checker():
+
+    def __init__(self):
+        pass
+
+    def getDocFiles(self, path=None):
+        files = []
+        if path is None:
+            path = ".{seper}docs{seper}cli".format(seper=os.path.sep)
+        for file in glob.glob(path + '/*', recursive=True):
+            if os.path.isdir(file):
+                files = files + self.getDocFiles(file)
+            else:
+                files.append(file)
+        return files
+
+    def readDocs(self, path=None):
+        files = self.getDocFiles(path)
+        commands = {}
+        click_regex = re.compile(r"\.\. click:: ([a-zA-Z0-9_\.:]*)")
+        prog_regex = re.compile(r"\W*:prog: (.*)")
+
+        for file in files:
+            click_line = ''
+            prog_line = ''
+            with open(file, 'r') as f:
+                for line in f:
+                    click_match = re.match(click_regex, line)
+                    prog_match = False
+                    if click_match:
+                        click_line = click_match.group(1)
+
+                    # Prog line should always be directly after click line.
+                        prog_match = re.match(prog_regex, f.readline())
+                    if prog_match:
+                        prog_line = prog_match.group(1).replace(" ", ":")
+                        commands[prog_line] = click_line
+                        click_line = ''
+                        prog_line = ''
+        # pp(commands)
+        return commands
+
+    def checkCommand(self, command, documented_commands):
+        """Sees if a command is documented
+
+        :param tuple command: like the entry in the routes file ('command:action', 'SoftLayer.CLI.module.function')
+        :param documented_commands: dictionary of commands found to be auto-documented.
+        """
+
+        # These commands use a slightly different loader. 
+        ignored = [
+            'virtual:capacity',
+            'virtual:placementgroup',
+            'object-storage:credential'
+        ]
+        if command[0] in ignored:
+            return True
+        if documented_commands.get(command[0], False) == command[1]:
+            return True
+        return False
+
+
+    def main(self, debug=0):
+        existing_commands = routes.ALL_ROUTES
+        documented_commands = self.readDocs()
+        # pp(documented_commands)
+        exitCode = 0
+        for command in existing_commands:
+            if (command[1].find(":") == -1):  # Header commands in the routes file, dont need documentaiton.
+                continue
+            else:
+                if self.checkCommand(command, documented_commands):
+                    if debug:
+                        print("{} is documented".format(command[0]))
+                    
+                else:
+                    print("===> {} {} IS UNDOCUMENTED <===".format(command[0], command[1]))
+                    exitCode = 1
+        sys.exit(exitCode)
+
+
+if __name__ == "__main__":
+    main = Checker()
+    main.main()
diff --git a/docs/cli/account.rst b/docs/cli/account.rst
index 9b3ad695..c34f37d7 100644
--- a/docs/cli/account.rst
+++ b/docs/cli/account.rst
@@ -22,4 +22,16 @@ Account Commands
 
 .. click:: SoftLayer.CLI.account.invoice_detail:cli
     :prog: account invoice-detail
+    :show-nested:
+
+.. click:: SoftLayer.CLI.account.billing_items:cli
+    :prog: account billing-items
+    :show-nested:
+
+.. click:: SoftLayer.CLI.account.item_detail:cli
+    :prog: account item-detail
+    :show-nested:
+
+.. click:: SoftLayer.CLI.account.cancel_item:cli
+    :prog: account cancel-item
     :show-nested:
\ No newline at end of file
diff --git a/docs/cli/block.rst b/docs/cli/block.rst
index 860872ce..8b31d5a9 100644
--- a/docs/cli/block.rst
+++ b/docs/cli/block.rst
@@ -130,3 +130,11 @@ Block Commands
 .. click:: SoftLayer.CLI.block.subnets.remove:cli
     :prog: block subnets-remove
     :show-nested:
+
+.. click:: SoftLayer.CLI.block.refresh:cli
+    :prog: block volume-refresh
+    :show-nested:
+
+.. click:: SoftLayer.CLI.block.convert:cli
+    :prog: block volume-convert
+    :show-nested:
diff --git a/docs/cli/file.rst b/docs/cli/file.rst
index 13cf92a6..52dad83a 100644
--- a/docs/cli/file.rst
+++ b/docs/cli/file.rst
@@ -110,3 +110,11 @@ File Commands
 .. click:: SoftLayer.CLI.file.snapshot.schedule_list:cli
     :prog: file snapshot-schedule-list
     :show-nested:
+
+.. click:: SoftLayer.CLI.file.refresh:cli
+    :prog: file volume-refresh
+    :show-nested:
+
+.. click:: SoftLayer.CLI.file.convert:cli
+    :prog: file volume-convert
+    :show-nested:
\ No newline at end of file
diff --git a/docs/cli/hardware.rst b/docs/cli/hardware.rst
index 3cd899d4..e99413c9 100644
--- a/docs/cli/hardware.rst
+++ b/docs/cli/hardware.rst
@@ -5,40 +5,40 @@ Interacting with Hardware
 
 
 .. click:: SoftLayer.CLI.hardware.bandwidth:cli
-   :prog: hw bandwidth
+   :prog: hardware bandwidth
    :show-nested:
 
 .. click:: SoftLayer.CLI.hardware.cancel_reasons:cli
-   :prog: hw cancel-reasons
+   :prog: hardware cancel-reasons
    :show-nested:
 
 .. click:: SoftLayer.CLI.hardware.cancel:cli
-   :prog: hw cancel
+   :prog: hardware cancel
    :show-nested:
 
 .. click:: SoftLayer.CLI.hardware.create_options:cli
-   :prog: hw create-options
+   :prog: hardware create-options
    :show-nested:
 
 .. click:: SoftLayer.CLI.hardware.create:cli
-   :prog: hw create
+   :prog: hardware create
    :show-nested:
 
 
 Provides some basic functionality to order a server. `slcli order` has a more full featured method of ordering servers. This command only supports the FAST_PROVISION type.
 
 .. click:: SoftLayer.CLI.hardware.credentials:cli
-   :prog: hw credentials
+   :prog: hardware credentials
    :show-nested:
 
 
 .. click:: SoftLayer.CLI.hardware.detail:cli
-   :prog: hw detail
+   :prog: hardware detail
    :show-nested:
 
 
 .. click:: SoftLayer.CLI.hardware.edit:cli
-   :prog: hw edit
+   :prog: hardware edit
    :show-nested:
 
 **Note :** Using multiple ' **:** ' can cause an error.
@@ -53,55 +53,55 @@ Provides some basic functionality to order a server. `slcli order` has a more fu
 When setting port speed, use "-1" to indicate best possible configuration. Using 10/100/1000/10000 on a server with a redundant interface may result the interface entering a degraded state. See `setPublicNetworkInterfaceSpeed <http://sldn.softlayer.com/reference/services/SoftLayer_Hardware_Server/setPublicNetworkInterfaceSpeed/>`_ for more information.
 
 .. click:: SoftLayer.CLI.hardware.list:cli
-   :prog: hw list
+   :prog: hardware list
    :show-nested:
 
 .. click:: SoftLayer.CLI.hardware.power:power_cycle
-   :prog: hw power-cycle
+   :prog: hardware power-cycle
    :show-nested:
 
 .. click:: SoftLayer.CLI.hardware.power:power_off
-   :prog: hw power-off
+   :prog: hardware power-off
    :show-nested:
 
 .. click:: SoftLayer.CLI.hardware.power:power_on
-   :prog: hw power-on
+   :prog: hardware power-on
    :show-nested:
 
 .. click:: SoftLayer.CLI.hardware.power:reboot
-   :prog: hw reboot
+   :prog: hardware reboot
    :show-nested:
 
 .. click:: SoftLayer.CLI.hardware.reload:cli
-   :prog: hw reload
+   :prog: hardware reload
    :show-nested:
 
 .. click:: SoftLayer.CLI.hardware.power:rescue
-   :prog: hw rescue
+   :prog: hardware rescue
 
 .. click:: SoftLayer.CLI.hardware.reflash_firmware:cli
-   :prog: hw reflash-firmware
+   :prog: hardware reflash-firmware
    :show-nested:
 
 
 Reflash here means the current version of the firmware running on your server will be re-flashed onto the selected hardware. This does require a reboot. See `slcli hw update-firmware` if you want the newest version.
 
 .. click:: SoftLayer.CLI.hardware.update_firmware:cli
-   :prog: hw update-firmware
+   :prog: hardware update-firmware
    :show-nested:
 
 
 This function updates the firmware of a server. If already at the latest version, no software is installed. 
 
 .. click:: SoftLayer.CLI.hardware.toggle_ipmi:cli
-   :prog: hw toggle-ipmi
+   :prog: hardware toggle-ipmi
    :show-nested:
 
 
 .. click:: SoftLayer.CLI.hardware.ready:cli
-   :prog: hw ready
+   :prog: hardware ready
    :show-nested:
 
 .. click:: SoftLayer.CLI.hardware.dns:cli
-   :prog: hw dns-sync
+   :prog: hardware dns-sync
    :show-nested:
diff --git a/docs/cli/image.rst b/docs/cli/image.rst
index 771abd16..93ba1332 100644
--- a/docs/cli/image.rst
+++ b/docs/cli/image.rst
@@ -26,3 +26,7 @@ Disk Image Commands
 .. click:: SoftLayer.CLI.image.export:cli
     :prog: image export
     :show-nested:
+
+.. click:: SoftLayer.CLI.image.datacenter:cli
+    :prog: image datacenter
+    :show-nested:
diff --git a/docs/cli/ipsec.rst b/docs/cli/ipsec.rst
index 2786a5ed..cc1ed5b1 100644
--- a/docs/cli/ipsec.rst
+++ b/docs/cli/ipsec.rst
@@ -14,6 +14,12 @@ To see more information about the IPSEC tunnel context module and API internacti
 
 ipsec list
 ----------
+
+
+.. click:: SoftLayer.CLI.vpn.ipsec.list:cli
+    :prog: ipsec list
+    :show-nested:
+
 A list of all IPSEC tunnel contexts associated with the current user's account can be retrieved via the ``ipsec list`` command. This provides a brief overview of all tunnel contexts and can be used to retrieve an individual context's identifier, which all other CLI commands require.
 ::
 
@@ -28,6 +34,12 @@ A list of all IPSEC tunnel contexts associated with the current user's account c
 
 ipsec detail
 ------------
+
+.. click:: SoftLayer.CLI.vpn.ipsec.detail:cli
+    :prog: ipsec detail
+    :show-nested:
+
+
 More detailed information can be retrieved for an individual context using the ``ipsec detail`` command. Using the detail command, information about associated internal subnets, remote subnets, static subnets, service subnets and address translations may also be retrieved using multiple instances of the ``-i|--include`` option.
 ::
 
@@ -91,6 +103,12 @@ More detailed information can be retrieved for an individual context using the `
 
 ipsec update
 ------------
+
+.. click:: SoftLayer.CLI.vpn.ipsec.update:cli
+    :prog: ipsec update
+    :show-nested:
+
+
 Most values listed in the tunnel context detail printout can be modified using the ``ipsec update`` command. The following is given when executing with the ``-h|--help`` option and highlights all properties that may be modified.
 ::
 
@@ -134,6 +152,12 @@ Most values listed in the tunnel context detail printout can be modified using t
 
 ipsec configure
 ---------------
+
+.. click:: SoftLayer.CLI.vpn.ipsec.configure:cli
+    :prog: ipsec configure
+    :show-nested:
+
+
 A request to configure SoftLayer network devices for a given tunnel context can be issued using the ``ipsec configure`` command.
 
 .. note::
@@ -144,6 +168,12 @@ A request to configure SoftLayer network devices for a given tunnel context can
 
 ipsec subnet-add
 ----------------
+
+.. click:: SoftLayer.CLI.vpn.ipsec.subnet.add:cli
+    :prog: ipsec subnet-add
+    :show-nested:
+
+
 Internal, remote and service subnets can be associated to an IPSEC tunnel context using the ``ipsec subnet-add`` command. Additionally, remote subnets can be created using this same command, which will then be associated to the targeted tunnel context.
 
 .. note::
@@ -167,6 +197,13 @@ The following is an example of creating and associating a remote subnet to a tun
 
 ipsec subnet-remove
 -------------------
+
+.. click:: SoftLayer.CLI.vpn.ipsec.subnet.remove:cli
+    :prog: ipsec subnet-remove
+    :show-nested:
+
+
+
 Internal, remote and service subnets can be disassociated from an IPSEC tunnel context via the ``ipsec subnet-remove`` command.
 
 .. note::
@@ -183,6 +220,12 @@ The following is an example of disassociating an internal subnet from a tunnel c
 
 ipsec translation-add
 ---------------------
+
+.. click:: SoftLayer.CLI.vpn.ipsec.translation.add:cli
+    :prog: ipsec translation-add
+    :show-nested:
+
+
 Address translation entries can be added to a tunnel context to provide NAT functionality from a statically routed subnet associated with the tunnel context to a remote subnet. This action is performed with the ``ipsec translation-add`` command.
 
 .. note::
@@ -199,6 +242,12 @@ The following is an example of adding a new address translation entry.
 
 ipsec translation-remove
 ------------------------
+
+.. click:: SoftLayer.CLI.vpn.ipsec.translation.remove:cli
+    :prog: ipsec translation-remove
+    :show-nested:
+
+
 Address translation entries can be removed using the ``ipsec translation-remove`` command.
 
 The following is an example of removing an address translation entry.
@@ -211,6 +260,11 @@ The following is an example of removing an address translation entry.
 
 ipsec translation-update
 ------------------------
+
+.. click:: SoftLayer.CLI.vpn.ipsec.translation.update:cli 
+    :prog: ipsec translation-update
+    :show-nested:
+
 Address translation entries may also be modified using the ``ipsec translation-update`` command.
 
 The following is an example of updating an existing address translation entry.
diff --git a/docs/cli/loadbal.rst b/docs/cli/loadbal.rst
index cec4200f..a4116b87 100644
--- a/docs/cli/loadbal.rst
+++ b/docs/cli/loadbal.rst
@@ -23,7 +23,7 @@ LBaaS Commands
    :prog: loadbal member-add
    :show-nested:
 .. click:: SoftLayer.CLI.loadbal.members:remove
-   :prog: loadbal member-remove
+   :prog: loadbal member-del
    :show-nested:
 .. click:: SoftLayer.CLI.loadbal.pools:add
    :prog: loadbal pool-add
@@ -32,7 +32,7 @@ LBaaS Commands
    :prog: loadbal pool-edit
    :show-nested:
 .. click:: SoftLayer.CLI.loadbal.pools:delete
-   :prog: loadbal pool-delete
+   :prog: loadbal pool-del
    :show-nested:
 .. click:: SoftLayer.CLI.loadbal.pools:l7pool_add
    :prog: loadbal l7pool-add
diff --git a/docs/cli/nas.rst b/docs/cli/nas.rst
new file mode 100644
index 00000000..02474491
--- /dev/null
+++ b/docs/cli/nas.rst
@@ -0,0 +1,12 @@
+.. _cli_nas:
+
+NAS Commands
+============
+
+.. click:: SoftLayer.CLI.nas.list:cli
+    :prog: nas list
+    :show-nested:
+
+.. click:: SoftLayer.CLI.nas.credentials:cli
+    :prog: nas credentials
+    :show-nested:
\ No newline at end of file
diff --git a/docs/cli/vs.rst b/docs/cli/vs.rst
index afa0f8b2..96bb5a9d 100644
--- a/docs/cli/vs.rst
+++ b/docs/cli/vs.rst
@@ -173,79 +173,91 @@ username is 'root' and password is 'ABCDEFGH'.
 
 
 .. click:: SoftLayer.CLI.virt.bandwidth:cli
-   :prog: vs bandwidth
+   :prog: virtual bandwidth
    :show-nested:
 
 If no timezone is specified, IMS local time (CST) will be assumed, which might not match your user's selected timezone.
 
 
 .. click:: SoftLayer.CLI.virt.cancel:cli
-   :prog: vs cancel
+   :prog: virtual cancel
    :show-nested:
 
 .. click:: SoftLayer.CLI.virt.capture:cli
-   :prog: vs capture
+   :prog: virtual capture
    :show-nested:
 
 .. click:: SoftLayer.CLI.virt.create:cli
-   :prog: vs create
+   :prog: virtual create
    :show-nested:
 
 .. click:: SoftLayer.CLI.virt.create_options:cli
-   :prog: vs create-options
+   :prog: virtual create-options
    :show-nested:
 
 .. click:: SoftLayer.CLI.virt.dns:cli
-   :prog: vs dns-sync
+   :prog: virtual dns-sync
    :show-nested:
 
 .. click:: SoftLayer.CLI.virt.edit:cli
-   :prog: vs edit
+   :prog: virtual edit
    :show-nested:
 
 .. click:: SoftLayer.CLI.virt.list:cli
-   :prog: vs list
+   :prog: virtual list
    :show-nested:
 
 .. click:: SoftLayer.CLI.virt.power:pause
-   :prog: vs pause
+   :prog: virtual pause
    :show-nested:
 
 
 .. click:: SoftLayer.CLI.virt.power:power_on
-   :prog: vs power-on
+   :prog: virtual power-on
    :show-nested:
 
 
 .. click:: SoftLayer.CLI.virt.power:power_off
-   :prog: vs power-off
+   :prog: virtual power-off
    :show-nested:
 
 .. click:: SoftLayer.CLI.virt.power:resume
-   :prog: vs resume
+   :prog: virtual resume
    :show-nested:
 
 .. click:: SoftLayer.CLI.virt.power:rescue
-   :prog: vs rescue
+   :prog: virtual rescue
    :show-nested:
 
 .. click:: SoftLayer.CLI.virt.power:reboot
-   :prog: vs reboot
+   :prog: virtual reboot
    :show-nested:
 
 .. click:: SoftLayer.CLI.virt.ready:cli
-   :prog: vs ready
+   :prog: virtual ready
    :show-nested:
 
 .. click:: SoftLayer.CLI.virt.upgrade:cli
-   :prog: vs upgrade
+   :prog: virtual upgrade
    :show-nested:
 
 .. click:: SoftLayer.CLI.virt.usage:cli
-   :prog: vs usage
+   :prog: virtual usage
    :show-nested:
 
 
+.. click:: SoftLayer.CLI.virt.detail:cli
+   :prog: virtual detail
+   :show-nested:
+
+
+.. click:: SoftLayer.CLI.virt.reload:cli
+   :prog: virtual reload
+   :show-nested:
+
+.. click:: SoftLayer.CLI.virt.credentials:cli
+   :prog: virtual credentials
+   :show-nested:
 
 
 Reserved Capacity
diff --git a/tox.ini b/tox.ini
index 22af5722..c9b3b5e7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py35,py36,py37,py38,pypy3,analysis,coverage
+envlist = py35,py36,py37,py38,pypy3,analysis,coverage,docs
 
 
 [flake8]
@@ -57,3 +57,7 @@ commands =
           --min-similarity-lines=50 \
           --max-line-length=120 \
           -r n
+
+[testenv:docs]
+commands = 
+    python ./docCheck.py
\ No newline at end of file


Test patch:
diff --git a/tests/CLI/modules/account_tests.py b/tests/CLI/modules/account_tests.py
index c495546c..e231bb2b 100644
--- a/tests/CLI/modules/account_tests.py
+++ b/tests/CLI/modules/account_tests.py
@@ -93,3 +93,21 @@ def test_account_summary(self):
         result = self.run_command(['account', 'summary'])
         self.assert_no_fail(result)
         self.assert_called_with('SoftLayer_Account', 'getObject')
+
+    # slcli account billing-items
+    def test_account_billing_items(self):
+        result = self.run_command(['account', 'billing-items'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Account', 'getAllTopLevelBillingItems')
+
+    # slcli account item-detail
+    def test_account_get_billing_item_detail(self):
+        result = self.run_command(['account', 'item-detail', '12345'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Billing_Item', 'getObject', identifier='12345')
+
+    # slcli account cancel-item
+    def test_account_cancel_item(self):
+        result = self.run_command(['account', 'cancel-item', '12345'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Billing_Item', 'cancelItem',  identifier='12345')
diff --git a/tests/managers/account_tests.py b/tests/managers/account_tests.py
index 7efc42ac..513aa44f 100644
--- a/tests/managers/account_tests.py
+++ b/tests/managers/account_tests.py
@@ -52,3 +52,43 @@ def test_get_invoices_closed(self):
     def test_get_billing_items(self):
         self.manager.get_billing_items(12345)
         self.assert_called_with('SoftLayer_Billing_Invoice', 'getInvoiceTopLevelItems')
+
+    def test_get_account_billing_items(self):
+        self.manager.get_account_billing_items()
+        object_filter = {
+            "allTopLevelBillingItems": {
+                "cancellationDate": {
+                    "operation": "is null"
+                },
+                "createDate": {
+                    'operation': 'orderBy',
+                    'options': [{
+                        'name': 'sort',
+                        'value': ['ASC']
+                    }]
+                }
+            }
+        }
+
+        self.assert_called_with('SoftLayer_Account', 'getAllTopLevelBillingItems',
+                                offset=0, limit=100, filter=object_filter)
+        self.manager.get_account_billing_items(mask="id")
+        self.assert_called_with('SoftLayer_Account', 'getAllTopLevelBillingItems', mask="mask[id]")
+
+    def test_get_billing_item(self):
+        self.manager.get_billing_item(12345)
+        self.assert_called_with('SoftLayer_Billing_Item', 'getObject', identifier=12345)
+        self.manager.get_billing_item(12345, mask="id")
+        self.assert_called_with('SoftLayer_Billing_Item', 'getObject', identifier=12345, mask="mask[id]")
+
+    def test_cancel_item(self):
+        self.manager.cancel_item(12345)
+        reason = "No longer needed"
+        note = "Cancelled by testAccount with the SLCLI"
+        self.assert_called_with('SoftLayer_Billing_Item', 'cancelItem',
+                                args=(False, True, reason, note), identifier=12345)
+        reason = "TEST"
+        note = "note test"
+        self.manager.cancel_item(12345, reason, note)
+        self.assert_called_with('SoftLayer_Billing_Item', 'cancelItem',
+                                args=(False, True, reason, note), identifier=12345)
diff --git a/tools/test-requirements.txt b/tools/test-requirements.txt
index 3080abf4..d417e04c 100644
--- a/tools/test-requirements.txt
+++ b/tools/test-requirements.txt
@@ -1,4 +1,5 @@
 tox
+coveralls
 pytest
 pytest-cov
 mock