Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sums up child items in invoices-detail #2203

Merged
merged 2 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions SoftLayer/CLI/account/invoice_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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([
'>>>',
Expand All @@ -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)
58 changes: 42 additions & 16 deletions SoftLayer/fixtures/SoftLayer_Billing_Invoice.py
Original file line number Diff line number Diff line change
@@ -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"}
}
]
31 changes: 29 additions & 2 deletions SoftLayer/testing/xmlrpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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."""
Expand Down
3 changes: 2 additions & 1 deletion SoftLayer/transports/xmlrpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
17 changes: 13 additions & 4 deletions tests/CLI/modules/account_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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')
Expand All @@ -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):
Expand Down
Loading