Skip to content

Commit 8d1b673

Browse files
authored
RSDK-4907 - Add billing client (#471)
1 parent b69b05d commit 8d1b673

File tree

6 files changed

+292
-0
lines changed

6 files changed

+292
-0
lines changed

src/viam/app/app_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,7 @@ async def get_robot_part(self, robot_part_id: str, dest: Optional[str] = None, i
656656
try:
657657
file = open(dest, "w")
658658
file.write(f"{json.dumps(json.loads(response.config_json), indent=indent)}")
659+
file.flush()
659660
except Exception as e:
660661
LOGGER.error(f"Failed to write config JSON to file {dest}", exc_info=e)
661662

@@ -716,6 +717,7 @@ async def get_robot_part_logs(
716717
file_name = log.caller["File"] + ":" + str(int(log.caller["Line"]))
717718
message = log.message
718719
file.write(f"{time}\t{level}\t{logger_name}\t{file_name:<64}{message}\n")
720+
file.flush()
719721
except Exception as e:
720722
LOGGER.error(f"Failed to write robot part from robot part with ID [{robot_part_id}]logs to file {dest}", exc_info=e)
721723

src/viam/app/billing_client.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from typing import Mapping, Optional
2+
3+
from grpclib.client import Channel
4+
5+
from viam import logging
6+
from viam.proto.app.billing import (
7+
BillingServiceStub,
8+
GetCurrentMonthUsageRequest,
9+
GetCurrentMonthUsageResponse,
10+
GetInvoicePdfRequest,
11+
GetInvoicePdfResponse,
12+
GetInvoicesSummaryRequest,
13+
GetInvoicesSummaryResponse,
14+
GetOrgBillingInformationRequest,
15+
GetOrgBillingInformationResponse,
16+
)
17+
18+
LOGGER = logging.getLogger(__name__)
19+
20+
21+
class BillingClient:
22+
"""gRPC client for retrieving billing data from app.
23+
24+
Constructor is used by `ViamClient` to instantiate relevant service stubs. Calls to
25+
`BillingClient` methods should be made through `ViamClient`.
26+
"""
27+
28+
def __init__(self, channel: Channel, metadata: Mapping[str, str]):
29+
"""Create a `BillingClient` that maintains a connection to app.
30+
31+
Args:
32+
channel (grpclib.client.Channel): Connection to app.
33+
metadata (Mapping[str, str]): Required authorization token to send requests to app.
34+
"""
35+
self._metadata = metadata
36+
self._billing_client = BillingServiceStub(channel)
37+
self._channel = channel
38+
39+
_billing_client: BillingServiceStub
40+
_channel: Channel
41+
_metadata: Mapping[str, str]
42+
43+
async def get_current_month_usage(self, org_id: str, timeout: Optional[float] = None) -> GetCurrentMonthUsageResponse:
44+
"""Access data usage information for the current month for a given organization.
45+
46+
Args:
47+
org_id (str): the ID of the organization to request usage data for
48+
49+
Returns:
50+
viam.proto.app.billing.GetCurrentMonthUsageResponse: Current month usage information
51+
"""
52+
request = GetCurrentMonthUsageRequest(org_id=org_id)
53+
return await self._billing_client.GetCurrentMonthUsage(request, metadata=self._metadata, timeout=timeout)
54+
55+
async def get_invoice_pdf(self, invoice_id: str, org_id: str, dest: str, timeout: Optional[float] = None) -> None:
56+
"""Access invoice PDF data and optionally save it to a provided file path.
57+
58+
Args:
59+
invoice_id (str): the ID of the invoice being requested
60+
org_id (str): the ID of the org to request data from
61+
dest (str): filepath to save the invoice to
62+
"""
63+
request = GetInvoicePdfRequest(id=invoice_id, org_id=org_id)
64+
response: GetInvoicePdfResponse = await self._billing_client.GetInvoicePdf(request, metadata=self._metadata, timeout=timeout)
65+
data: bytes = response[0].chunk
66+
with open(dest, "wb") as file:
67+
file.write(data)
68+
69+
async def get_invoices_summary(self, org_id: str, timeout: Optional[float] = None) -> GetInvoicesSummaryResponse:
70+
"""Access total outstanding balance plus invoice summaries for a given org.
71+
72+
Args:
73+
org_id (str): the ID of the org to request data for
74+
75+
Returns:
76+
viam.proto.app.billing.GetInvoicesSummaryResponse: Summary of org invoices
77+
"""
78+
request = GetInvoicesSummaryRequest(org_id=org_id)
79+
return await self._billing_client.GetInvoicesSummary(request, metadata=self._metadata, timeout=timeout)
80+
81+
async def get_org_billing_information(self, org_id: str, timeout: Optional[float] = None) -> GetOrgBillingInformationResponse:
82+
"""Access billing information (payment method, billing tier, etc.) for a given org.
83+
84+
Args:
85+
org_id (str): the ID of the org to request data for
86+
87+
Returns:
88+
viam.proto.app.billing.GetOrgBillingInformationResponse: The org billing information"""
89+
request = GetOrgBillingInformationRequest(org_id=org_id)
90+
return await self._billing_client.GetOrgBillingInformation(request, metadata=self._metadata, timeout=timeout)

src/viam/app/data_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ async def tabular_data_by_filter(
170170
try:
171171
file = open(dest, "w")
172172
file.write(f"{[str(d) for d in data]}")
173+
file.flush()
173174
except Exception as e:
174175
LOGGER.error(f"Failed to write tabular data to file {dest}", exc_info=e)
175176
return data
@@ -222,6 +223,7 @@ async def binary_data_by_filter(
222223
try:
223224
file = open(dest, "w")
224225
file.write(f"{[str(d) for d in data]}")
226+
file.flush()
225227
except Exception as e:
226228
LOGGER.error(f"Failed to write binary data to file {dest}", exc_info=e)
227229

@@ -256,6 +258,7 @@ async def binary_data_by_ids(
256258
try:
257259
file = open(dest, "w")
258260
file.write(f"{response.data}")
261+
file.flush()
259262
except Exception as e:
260263
LOGGER.error(f"Failed to write binary data to file {dest}", exc_info=e)
261264
return [DataClient.BinaryData(data.binary, data.metadata) for data in response.data]

src/viam/app/viam_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from viam import logging
77
from viam.app.app_client import AppClient
8+
from viam.app.billing_client import BillingClient
89
from viam.app.data_client import DataClient
910
from viam.app.ml_training_client import MLTrainingClient
1011
from viam.rpc.dial import DialOptions, _dial_app, _get_access_token
@@ -74,6 +75,11 @@ def ml_training_client(self) -> MLTrainingClient:
7475
"""Instantiate and return a `MLTrainingClient` used to make `ml_training` method calls."""
7576
return MLTrainingClient(self._channel, self._metadata)
7677

78+
@property
79+
def billing_client(self) -> BillingClient:
80+
"""Instantiate and return a `BillingClient` used to make `billing` method calls."""
81+
return BillingClient(self._channel, self._metadata)
82+
7783
def close(self):
7884
"""Close opened channels used for the various service stubs initialized."""
7985
if self._closed:

tests/mocks/services.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,27 @@
211211
SubmitTrainingJobResponse,
212212
TrainingJobMetadata,
213213
)
214+
from viam.proto.app.billing import (
215+
BillingServiceBase,
216+
GetCurrentMonthUsageRequest,
217+
GetCurrentMonthUsageResponse,
218+
GetInvoicePdfRequest,
219+
GetInvoicePdfResponse,
220+
GetInvoicesSummaryRequest,
221+
GetInvoicesSummaryResponse,
222+
GetOrgBillingInformationRequest,
223+
GetOrgBillingInformationResponse,
224+
GetBillingSummaryRequest,
225+
GetBillingSummaryResponse,
226+
GetCurrentMonthUsageSummaryRequest,
227+
GetCurrentMonthUsageSummaryResponse,
228+
GetInvoiceHistoryRequest,
229+
GetInvoiceHistoryResponse,
230+
GetItemizedInvoiceRequest,
231+
GetItemizedInvoiceResponse,
232+
GetUnpaidBalanceRequest,
233+
GetUnpaidBalanceResponse,
234+
)
214235
from viam.proto.common import DoCommandRequest, DoCommandResponse, GeoObstacle, GeoPoint, PointCloudObject, Pose, PoseInFrame, ResourceName
215236
from viam.proto.service.mlmodel import (
216237
FlatTensor,
@@ -818,6 +839,64 @@ async def CancelTrainingJob(self, stream: Stream[CancelTrainingJobRequest, Cance
818839
await stream.send_message(CancelTrainingJobResponse())
819840

820841

842+
class MockBilling(BillingServiceBase):
843+
def __init__(
844+
self,
845+
pdf: bytes,
846+
curr_month_usage: GetCurrentMonthUsageResponse,
847+
invoices_summary: GetInvoicesSummaryResponse,
848+
billing_info: GetOrgBillingInformationResponse,
849+
):
850+
self.pdf = pdf
851+
self.curr_month_usage = curr_month_usage
852+
self.invoices_summary = invoices_summary
853+
self.billing_info = billing_info
854+
855+
async def GetCurrentMonthUsage(self, stream: Stream[GetCurrentMonthUsageRequest, GetCurrentMonthUsageResponse]) -> None:
856+
request = await stream.recv_message()
857+
assert request is not None
858+
self.org_id = request.org_id
859+
await stream.send_message(self.curr_month_usage)
860+
861+
async def GetInvoicePdf(self, stream: Stream[GetInvoicePdfRequest, GetInvoicePdfResponse]) -> None:
862+
request = await stream.recv_message()
863+
assert request is not None
864+
self.org_id = request.org_id
865+
self.invoice_id = request.id
866+
response = GetInvoicePdfResponse(chunk=self.pdf)
867+
await stream.send_message(response)
868+
869+
async def GetInvoicesSummary(self, stream: Stream[GetInvoicesSummaryRequest, GetInvoicePdfResponse]) -> None:
870+
request = await stream.recv_message()
871+
assert request is not None
872+
self.org_id = request.org_id
873+
await stream.send_message(self.invoices_summary)
874+
875+
async def GetOrgBillingInformation(self, stream: Stream[GetOrgBillingInformationRequest, GetOrgBillingInformationResponse]) -> None:
876+
request = await stream.recv_message()
877+
assert request is not None
878+
self.org_id = request.org_id
879+
await stream.send_message(self.billing_info)
880+
881+
async def GetBillingSummary(self, stream: Stream[GetBillingSummaryRequest, GetBillingSummaryResponse]) -> None:
882+
raise NotImplementedError()
883+
884+
async def GetCurrentMonthUsageSummary(
885+
self,
886+
stream: Stream[GetCurrentMonthUsageSummaryRequest, GetCurrentMonthUsageSummaryResponse],
887+
) -> None:
888+
raise NotImplementedError()
889+
890+
async def GetInvoiceHistory(self, stream: Stream[GetInvoiceHistoryRequest, GetInvoiceHistoryResponse]) -> None:
891+
raise NotImplementedError()
892+
893+
async def GetItemizedInvoice(self, stream: Stream[GetItemizedInvoiceRequest, GetItemizedInvoiceResponse]) -> None:
894+
raise NotImplementedError()
895+
896+
async def GetUnpaidBalance(self, stream: Stream[GetUnpaidBalanceRequest, GetUnpaidBalanceResponse]) -> None:
897+
raise NotImplementedError()
898+
899+
821900
class MockApp(AppServiceBase):
822901
def __init__(
823902
self,

tests/test_billing_client.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import pytest
2+
3+
from google.protobuf.timestamp_pb2 import Timestamp
4+
from grpclib.testing import ChannelFor
5+
6+
from viam.app.billing_client import BillingClient
7+
from viam.proto.app.billing import (
8+
GetCurrentMonthUsageResponse,
9+
GetInvoicesSummaryResponse,
10+
GetOrgBillingInformationResponse,
11+
InvoiceSummary,
12+
)
13+
14+
from .mocks.services import MockBilling
15+
16+
PDF = b'abc123'
17+
CLOUD_STORAGE_USAGE_COST = 100.0
18+
DATA_UPLOAD_USAGE_COST = 101.0
19+
DATA_EGRES_USAGE_COST = 102.0
20+
REMOTE_CONTROL_USAGE_COST = 103.0
21+
STANDARD_COMPUTE_USAGE_COST = 104.0
22+
DISCOUNT_AMOUNT = 0.0
23+
TOTAL_USAGE_WITH_DISCOUNT = 105.0
24+
TOTAL_USAGE_WITHOUT_DISCOUNT = 106.0
25+
OUTSTANDING_BALANCE = 1000.0
26+
SECONDS_START = 978310861
27+
NANOS_START = 0
28+
SECONDS_END = 998310861
29+
NANOS_END = 0
30+
SECONDS_PAID = 988310861
31+
NANOS_PAID = 0
32+
START_TS = Timestamp(seconds=SECONDS_START, nanos=NANOS_END)
33+
PAID_DATE_TS = Timestamp(seconds=SECONDS_PAID, nanos=NANOS_PAID)
34+
END_TS = Timestamp(seconds=SECONDS_END, nanos=NANOS_END)
35+
INVOICE_ID = "invoice"
36+
STATUS = "status"
37+
PAYMENT_TYPE = 1
38+
39+
BILLING_TIER = "tier"
40+
INVOICE = InvoiceSummary(
41+
id=INVOICE_ID,
42+
invoice_date=START_TS,
43+
invoice_amount=OUTSTANDING_BALANCE,
44+
status=STATUS,
45+
due_date=END_TS,
46+
paid_date=PAID_DATE_TS,
47+
)
48+
INVOICES = [INVOICE]
49+
CURR_MONTH_USAGE = GetCurrentMonthUsageResponse(
50+
start_date=START_TS,
51+
end_date=END_TS,
52+
cloud_storage_usage_cost=CLOUD_STORAGE_USAGE_COST,
53+
data_upload_usage_cost=DATA_UPLOAD_USAGE_COST,
54+
data_egres_usage_cost=DATA_EGRES_USAGE_COST,
55+
remote_control_usage_cost=REMOTE_CONTROL_USAGE_COST,
56+
standard_compute_usage_cost=STANDARD_COMPUTE_USAGE_COST,
57+
discount_amount=DISCOUNT_AMOUNT,
58+
total_usage_with_discount=TOTAL_USAGE_WITH_DISCOUNT,
59+
total_usage_without_discount=TOTAL_USAGE_WITHOUT_DISCOUNT,
60+
)
61+
INVOICES_SUMMARY = GetInvoicesSummaryResponse(outstanding_balance=OUTSTANDING_BALANCE, invoices=INVOICES)
62+
ORG_BILLING_INFO = GetOrgBillingInformationResponse(
63+
type=PAYMENT_TYPE,
64+
billing_email=EMAIL,
65+
billing_tier=BILLING_TIER,
66+
)
67+
68+
AUTH_TOKEN = "auth_token"
69+
BILLING_SERVICE_METADATA = {"authorization": f"Bearer {AUTH_TOKEN}"}
70+
71+
72+
@pytest.fixture(scope="function")
73+
def service() -> MockBilling:
74+
return MockBilling(
75+
pdf=PDF,
76+
curr_month_usage=CURR_MONTH_USAGE,
77+
invoices_summary=INVOICES_SUMMARY,
78+
billing_info=ORG_BILLING_INFO,
79+
)
80+
81+
82+
class TestClient:
83+
@pytest.mark.asyncio
84+
async def test_get_current_month_usage(self, service: MockBilling):
85+
async with ChannelFor([service]) as channel:
86+
org_id = "foo"
87+
client = BillingClient(channel, BILLING_SERVICE_METADATA)
88+
curr_month_usage = await client.get_current_month_usage(org_id=org_id)
89+
assert curr_month_usage == CURR_MONTH_USAGE
90+
assert service.org_id == org_id
91+
92+
@pytest.mark.asyncio
93+
async def test_get_invoice_pdf(self, service: MockBilling):
94+
assert True
95+
96+
@pytest.mark.asyncio
97+
async def test_get_invoices_summary(self, service: MockBilling):
98+
async with ChannelFor([service]) as channel:
99+
org_id = "bar"
100+
client = BillingClient(channel, BILLING_SERVICE_METADATA)
101+
invoices_summary = await client.get_invoices_summary(org_id=org_id)
102+
assert invoices_summary == INVOICES_SUMMARY
103+
assert service.org_id == org_id
104+
105+
@pytest.mark.asyncio
106+
async def test_get_org_billing_information(self, service: MockBilling):
107+
async with ChannelFor([service]) as channel:
108+
org_id = "baz"
109+
client = BillingClient(channel, BILLING_SERVICE_METADATA)
110+
org_billing_info = await client.get_org_billing_information(org_id=org_id)
111+
assert org_billing_info == ORG_BILLING_INFO
112+
assert service.org_id == org_id

0 commit comments

Comments
 (0)