Repository: softlayer/softlayer-python

Issue:
Sum up invoice items for invoice-details 
Invoice Top Level item should at least be the sum of itself and its children.
That top entry should not be 0.00$ (should be 60.57$)
```
 3097434296                       Server                                     Dual Intel Xeon Silver 4110 (16 Cores, 2.10 GHz) (ocp-master-3.chechu.me)                 $0.00    $0.00   2024-12-04    fra02
    >>>                      Second Processor                                               Intel Xeon Silver 4110 (16 Cores, 2.10 GHz)                                $0.00    $0.00       ---        --- 
    >>>                      Operating System                                                           No Operating System                                            $0.00    $0.00       ---        ---
    >>>                      Disk Controller                                                                   RAID 1                                                  $0.00   $60.57       ---        ---
    >>>                      First Hard Drive                                                          960GB SSD SED (5DWPD)                                           $0.00    $0.00       ---        ---
    >>>                      Public Bandwidth                                                         0 GB Bandwidth Allotment                                         $0.00    $0.00       ---        ---
    >>>                     Uplink Port Speeds                                                1 Gbps Redundant Private Network Uplinks                                 $0.00    $0.00       ---        ---
    >>>                    Public Network Port                                                          0 Mbps Public Uplink                                           $0.00    $0.00       ---        ---
    >>>                    Primary IP Addresses                                                             1 IP Address                                               $0.00    $0.00       ---        ---
    >>>                     Second Hard Drive                                                            960GB SSD (3 DWPD)                                            $0.00    $0.00       ---        ---
    >>>                      Third Hard Drive                                                            3.2TB NVMe SSD U.2                                            $0.00    $0.00       ---        ---
    >>>                     Fourth Hard Drive                                                            3.2TB NVMe SSD U.2                                            $0.00    $0.00       ---        --- 
    >>>                            RAM                                                                       128 GB RAM                                                $0.00    $0.00       ---        ---
```

see also https://github.ibm.com/SoftLayer/softlayer-cli/issues/884

Discussion:
@allmightyspiff: ![image](https://github.com/user-attachments/assets/e988d716-101c-448e-a606-a3e72680660e)


Solution patch:
diff --git a/SoftLayer/CLI/account/invoice_detail.py b/SoftLayer/CLI/account/invoice_detail.py
index 281940ee5..4436c44d9 100644
--- a/SoftLayer/CLI/account/invoice_detail.py
+++ b/SoftLayer/CLI/account/invoice_detail.py
@@ -16,7 +16,13 @@
               help="Shows a very detailed list of charges")
 @environment.pass_env
 def cli(env, identifier, details):
-    """Invoice details"""
+    """Invoice details
+
+    Will display the top level invoice items for a given invoice. The cost displayed is the sum of the item's
+    cost along with all its child items.
+    The --details option will display any child items a top level item may have. Parent items will appear
+    in this list as well to display their specific cost.
+    """
 
     manager = AccountManager(env.client)
     top_items = manager.get_billing_items(identifier)
@@ -49,16 +55,31 @@ def get_invoice_table(identifier, top_items, details):
         description = nice_string(item.get('description'))
         if fqdn != '.':
             description = "%s (%s)" % (item.get('description'), fqdn)
+        total_recur, total_single = sum_item_charges(item)
         table.add_row([
             item.get('id'),
             category,
             nice_string(description),
-            "$%.2f" % float(item.get('oneTimeAfterTaxAmount')),
-            "$%.2f" % float(item.get('recurringAfterTaxAmount')),
+            f"${total_single:,.2f}",
+            f"${total_recur:,.2f}",
             utils.clean_time(item.get('createDate'), out_format="%Y-%m-%d"),
             utils.lookup(item, 'location', 'name')
         ])
         if details:
+            # This item has children, so we want to print out the parent item too. This will match the
+            # invoice from the portal. https://github.com/softlayer/softlayer-python/issues/2201
+            if len(item.get('children')) > 0:
+                single = float(item.get('oneTimeAfterTaxAmount', 0.0))
+                recurring = float(item.get('recurringAfterTaxAmount', 0.0))
+                table.add_row([
+                    '>>>',
+                    category,
+                    nice_string(description),
+                    f"${single:,.2f}",
+                    f"${recurring:,.2f}",
+                    '---',
+                    '---'
+                ])
             for child in item.get('children', []):
                 table.add_row([
                     '>>>',
@@ -70,3 +91,16 @@ def get_invoice_table(identifier, top_items, details):
                     '---'
                 ])
     return table
+
+
+def sum_item_charges(item: dict) -> (float, float):
+    """Takes a billing Item, sums up its child items and returns recurring, one_time prices"""
+
+    # API returns floats as strings in this case
+    single = float(item.get('oneTimeAfterTaxAmount', 0.0))
+    recurring = float(item.get('recurringAfterTaxAmount', 0.0))
+    for child in item.get('children', []):
+        single = single + float(child.get('oneTimeAfterTaxAmount', 0.0))
+        recurring = recurring + float(child.get('recurringAfterTaxAmount', 0.0))
+
+    return (recurring, single)
diff --git a/SoftLayer/fixtures/SoftLayer_Billing_Invoice.py b/SoftLayer/fixtures/SoftLayer_Billing_Invoice.py
index d4d89131c..eb9e1171d 100644
--- a/SoftLayer/fixtures/SoftLayer_Billing_Invoice.py
+++ b/SoftLayer/fixtures/SoftLayer_Billing_Invoice.py
@@ -1,23 +1,49 @@
 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': [
+        "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
+                "id": 12345,
+                "category": {"name": "Fake Child Category"},
+                "description": "Blah",
+                "oneTimeAfterTaxAmount": 55.50,
+                "recurringAfterTaxAmount": 0.10
             }
         ],
-        'location': {'name': 'fra02'}
+        "location": {"name": "fra02"}
+    },
+    {
+        "categoryCode": "reserved_capacity",
+        "createDate": "2024-07-03T22:08:36-07:00",
+        "description": "B1.1x2 (1 Year Term) (721hrs * .025)",
+        "id": 1111222,
+        "oneTimeAfterTaxAmount": "0",
+        "recurringAfterTaxAmount": "18.03",
+        "category": {"name": "Reserved Capacity"},
+        "children": [
+            {
+                "description": "1 x 2.0 GHz or higher Core",
+                "id": 29819,
+                "oneTimeAfterTaxAmount": "0",
+                "recurringAfterTaxAmount": "10.00",
+                "category": {"name": "Computing Instance"}
+            },
+            {
+                "description": "2 GB",
+                "id": 123456,
+                "oneTimeAfterTaxAmount": "0",
+                "recurringAfterTaxAmount": "2.33",
+                "category": {"name": "RAM"}
+            }
+        ],
+        "location": {"name": "dal10"}
     }
 ]
diff --git a/SoftLayer/testing/xmlrpc.py b/SoftLayer/testing/xmlrpc.py
index a572fd79d..e0e7e5ca2 100644
--- a/SoftLayer/testing/xmlrpc.py
+++ b/SoftLayer/testing/xmlrpc.py
@@ -3,6 +3,25 @@
     ~~~~~~~~~~~~~~~~~~~~~~~~
     XMP-RPC server which can use a transport to proxy requests for testing.
 
+    If you want to spin up a test XML server to make fake API calls with, try this:
+
+    quick-server.py
+    ---
+    import SoftLayer
+    from SoftLayer.testing import xmlrpc
+
+    my_xport = SoftLayer.FixtureTransport()
+    my_server = xmlrpc.create_test_server(my_xport, "localhost", port=4321)
+    print(f"Server running on http://{my_server.server_name}:{my_server.server_port}")
+    ---
+    $> python quick-server.py
+    $> curl -X POST -d "<?xml version='1.0' encoding='iso-8859-1'?><methodCall><methodName> \
+getInvoiceTopLevelItems</methodName><params><param><value><struct><member><name>headers</name> \
+<value><struct><member><name>SoftLayer_Billing_InvoiceInitParameters</name><value><struct> \
+<member><name>id</name><value><string>1234</string></value></member></struct></value></member> \
+</struct></value></member></struct></value></param></params></methodCall>" \
+http://127.0.0.1:4321/SoftLayer_Billing_Invoice
+
     :license: MIT, see LICENSE for more details.
 """
 import http.server
@@ -60,6 +79,7 @@ def do_POST(self):
             self.send_response(200)
             self.send_header("Content-type", "application/xml; charset=UTF-8")
             self.end_headers()
+
             try:
                 self.wfile.write(response_body.encode('utf-8'))
             except UnicodeDecodeError:
@@ -78,9 +98,16 @@ def do_POST(self):
             response = xmlrpc.client.Fault(ex.faultCode, str(ex.reason))
             response_body = xmlrpc.client.dumps(response, allow_none=True, methodresponse=True)
             self.wfile.write(response_body.encode('utf-8'))
-        except Exception:
+        except OverflowError as ex:
+            self.send_response(555)
+            self.send_header("Content-type", "application/xml; charset=UTF-8")
+            self.end_headers()
+            response_body = '''<error>OverflowError in XML response.</error>'''
+            self.wfile.write(response_body.encode('utf-8'))
+            logging.exception("Error while handling request: %s", ex)
+        except Exception as ex:
             self.send_response(500)
-            logging.exception("Error while handling request")
+            logging.exception("Error while handling request: %s", ex)
 
     def log_message(self, fmt, *args):
         """Override log_message."""
diff --git a/SoftLayer/transports/xmlrpc.py b/SoftLayer/transports/xmlrpc.py
index 66cdb5707..16456eda9 100644
--- a/SoftLayer/transports/xmlrpc.py
+++ b/SoftLayer/transports/xmlrpc.py
@@ -121,7 +121,8 @@ def __call__(self, request):
             _ex = error_mapping.get(ex.faultCode, exceptions.SoftLayerAPIError)
             raise _ex(ex.faultCode, ex.faultString) from ex
         except requests.HTTPError as ex:
-            raise exceptions.TransportError(ex.response.status_code, str(ex))
+            err_message = f"{str(ex)} :: {ex.response.content}"
+            raise exceptions.TransportError(ex.response.status_code, err_message)
         except requests.RequestException as ex:
             raise exceptions.TransportError(0, str(ex))
 


Test patch:
diff --git a/tests/CLI/modules/account_tests.py b/tests/CLI/modules/account_tests.py
index 06c718cb4..9dd4dd905 100644
--- a/tests/CLI/modules/account_tests.py
+++ b/tests/CLI/modules/account_tests.py
@@ -44,14 +44,11 @@ def test_event_jsonraw_output(self):
         command = '--format jsonraw account events'
         command_params = command.split()
         result = self.run_command(command_params)
-
         json_text_tables = result.stdout.split('\n')
-        print(f"RESULT: {result.output}")
         # removing an extra item due to an additional Newline at the end of the output
         json_text_tables.pop()
         # each item in the json_text_tables should be a list
         for json_text_table in json_text_tables:
-            print(f"TESTING THIS: \n{json_text_table}\n")
             json_table = json.loads(json_text_table)
             self.assertIsInstance(json_table, list)
 
@@ -66,6 +63,18 @@ def test_invoice_detail_details(self):
         self.assert_no_fail(result)
         self.assert_called_with('SoftLayer_Billing_Invoice', 'getInvoiceTopLevelItems', identifier='1234')
 
+    def test_invoice_detail_sum_children(self):
+        result = self.run_command(['--format=json', 'account', 'invoice-detail', '1234', '--details'])
+        self.assert_no_fail(result)
+        json_out = json.loads(result.output)
+        self.assertEqual(len(json_out), 7)
+        self.assertEqual(json_out[0]['Item Id'], 724951323)
+        self.assertEqual(json_out[0]['Single'], '$55.50')
+        self.assertEqual(json_out[0]['Monthly'], '$0.10')
+        self.assertEqual(json_out[3]['Item Id'], 1111222)
+        self.assertEqual(json_out[3]['Single'], '$0.00')
+        self.assertEqual(json_out[3]['Monthly'], '$30.36')
+
     def test_invoice_detail_csv_output_format(self):
         result = self.run_command(["--format", "csv", 'account', 'invoice-detail', '1234'])
         result_output = result.output.replace('\r', '').split('\n')
@@ -74,7 +83,7 @@ def test_invoice_detail_csv_output_format(self):
                          '"Create Date","Location"')
         self.assertEqual(result_output[1], '724951323,"Private (only) Secondary VLAN IP Addresses",'
                                            '"64 Portable Private IP Addresses (bleg.beh.com)",'
-                                           '"$0.00","$0.00","2018-04-04","fra02"')
+                                           '"$55.50","$0.10","2018-04-04","fra02"')
 
     # slcli account invoices
     def test_invoices(self):