python-mytnb 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/.github/workflows/ci.yml +2 -2
  2. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/.github/workflows/publish.yml +2 -3
  3. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/PKG-INFO +27 -15
  4. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/README.md +26 -14
  5. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/pyproject.toml +1 -1
  6. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/__init__.py +2 -0
  7. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/cli.py +42 -0
  8. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/client/client.py +44 -18
  9. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/client/config.py +1 -0
  10. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/client/legacy.py +7 -0
  11. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/models.py +104 -2
  12. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/tests/test_cli.py +60 -2
  13. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/tests/test_client.py +64 -1
  14. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/tests/test_models.py +139 -0
  15. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/uv.lock +1 -1
  16. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/.gitignore +0 -0
  17. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/LICENSE +0 -0
  18. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/__main__.py +0 -0
  19. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/auth.py +0 -0
  20. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/client/__init__.py +0 -0
  21. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/client/auth.py +0 -0
  22. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/client/rest.py +0 -0
  23. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/crypto.py +0 -0
  24. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/src/mytnb/exceptions.py +0 -0
  25. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/tests/test_auth.py +0 -0
  26. {python_mytnb-0.1.0 → python_mytnb-0.2.0}/tests/test_crypto.py +0 -0
@@ -2,9 +2,9 @@ name: CI
2
2
 
3
3
  on:
4
4
  push:
5
- branches: [dev, main]
5
+ branches: [dev, master]
6
6
  pull_request:
7
- branches: [dev, main]
7
+ branches: [dev, master]
8
8
 
9
9
  jobs:
10
10
  lint:
@@ -1,9 +1,8 @@
1
1
  name: Publish to PyPI
2
2
 
3
3
  on:
4
- push:
5
- tags:
6
- - "v*"
4
+ release:
5
+ types: [published]
7
6
 
8
7
  jobs:
9
8
  publish:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mytnb
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Python library to interface with the myTNB API (Tenaga Nasional Berhad)
5
5
  Project-URL: Repository, https://github.com/danieyal/python-mytnb
6
6
  License-Expression: MIT
@@ -29,6 +29,9 @@ Requires-Dist: pytest>=7.0; extra == 'dev'
29
29
  Requires-Dist: ruff>=0.6; extra == 'dev'
30
30
  Description-Content-Type: text/markdown
31
31
 
32
+ [![PyPI version](https://badge.fury.io/py/python-mytnb.svg)](https://badge.fury.io/py/python-mytnb)
33
+ [![CI](https://github.com/danieyal/python-mytnb/actions/workflows/ci.yml/badge.svg)](https://github.com/danieyal/python-mytnb/actions/workflows/ci.yml)
34
+
32
35
  # python-mytnb
33
36
 
34
37
  A Python library to interface with the myTNB (Tenaga Nasional Berhad) API for electricity account management and usage monitoring in Malaysia.
@@ -55,6 +58,11 @@ async def main():
55
58
  client = await MyTNBClient.login("user@example.com", "your-password")
56
59
 
57
60
  async with client:
61
+ # Auto-discover linked accounts
62
+ accounts = await client.get_customer_accounts()
63
+ for acc in accounts:
64
+ print(f"{acc.account_number} — {acc.owner_name} (SMR: {acc.is_smart_meter})")
65
+
58
66
  # Get smart meter usage & billing history
59
67
  usage = await client.get_account_usage_smart("220123456789")
60
68
 
@@ -89,6 +97,8 @@ All commands:
89
97
 
90
98
  ```
91
99
  mytnb login # Test login, show user info
100
+ mytnb accounts # List all linked accounts (auto-discovery)
101
+ mytnb accounts --json # Account list as JSON
92
102
  mytnb usage <account> # Monthly usage & billing summary
93
103
  mytnb usage --daily <account> # Daily usage breakdown
94
104
  mytnb usage --json <account> # Full usage data as JSON
@@ -109,25 +119,27 @@ mytnb init-config # Generate a starter config file
109
119
 
110
120
  myTNB uses two API backends, both handled transparently by this library:
111
121
 
112
- | Backend | Domain | Auth | Used for |
113
- | ----------- | --------------------- | ------------------------------------------- | ----------------------------------- |
114
- | REST | `api.mytnb.com.my` | JWT + API key | Account listing, eligibility checks |
115
- | Legacy ASMX | `mytnbapp.tnb.com.my` | Encrypted payloads (AES-256-CBC + RSA-OAEP) | Usage data, billing, services |
122
+ | Backend | Domain | Auth | Used for |
123
+ | ----------- | -------------------------- | ------------------------------------------- | ----------------------------------- |
124
+ | REST | `api.mytnb.com.my` | JWT + API key | Bill eligibility, eligibility icons |
125
+ | AWS Gateway | `api.mytnb.com.my/core/api`| Encrypted payloads (AES-256-CBC + RSA-OAEP) | Account listing (auto-discovery) |
126
+ | Legacy ASMX | `mytnbapp.tnb.com.my` | Encrypted payloads (AES-256-CBC + RSA-OAEP) | Usage data, billing, services |
116
127
 
117
128
  Request encryption for the ASMX API is automatic — just pass plaintext parameters.
118
129
 
119
130
  ## Data Models
120
131
 
121
- | Model | Description |
122
- | --------------- | ---------------------------------------------------- |
123
- | `AccountUsage` | Full usage response: metrics, monthly and daily data |
124
- | `UsageMetric` | Current/average usage (kWh) |
125
- | `CostMetric` | Current/projected cost (RM) |
126
- | `BillingMonth` | Monthly billing record with tariff blocks |
127
- | `DailyUsage` | Daily consumption and cost |
128
- | `TariffBlock` | Tariff pricing block details |
129
- | `SMRAccount` | Smart Meter Reading eligibility status |
130
- | `BREligibility` | Bill rendering opt-in status |
132
+ | Model | Description |
133
+ | ----------------- | ---------------------------------------------------- |
134
+ | `CustomerAccount` | Linked account: number, owner, address, SMR status |
135
+ | `AccountUsage` | Full usage response: metrics, monthly and daily data |
136
+ | `UsageMetric` | Current/average usage (kWh) |
137
+ | `CostMetric` | Current/projected cost (RM) |
138
+ | `BillingMonth` | Monthly billing record with tariff blocks |
139
+ | `DailyUsage` | Daily consumption and cost |
140
+ | `TariffBlock` | Tariff pricing block details |
141
+ | `SMRAccount` | Smart Meter Reading eligibility status |
142
+ | `BREligibility` | Bill rendering opt-in status |
131
143
 
132
144
  ## Geographic Restrictions
133
145
 
@@ -1,3 +1,6 @@
1
+ [![PyPI version](https://badge.fury.io/py/python-mytnb.svg)](https://badge.fury.io/py/python-mytnb)
2
+ [![CI](https://github.com/danieyal/python-mytnb/actions/workflows/ci.yml/badge.svg)](https://github.com/danieyal/python-mytnb/actions/workflows/ci.yml)
3
+
1
4
  # python-mytnb
2
5
 
3
6
  A Python library to interface with the myTNB (Tenaga Nasional Berhad) API for electricity account management and usage monitoring in Malaysia.
@@ -24,6 +27,11 @@ async def main():
24
27
  client = await MyTNBClient.login("user@example.com", "your-password")
25
28
 
26
29
  async with client:
30
+ # Auto-discover linked accounts
31
+ accounts = await client.get_customer_accounts()
32
+ for acc in accounts:
33
+ print(f"{acc.account_number} — {acc.owner_name} (SMR: {acc.is_smart_meter})")
34
+
27
35
  # Get smart meter usage & billing history
28
36
  usage = await client.get_account_usage_smart("220123456789")
29
37
 
@@ -58,6 +66,8 @@ All commands:
58
66
 
59
67
  ```
60
68
  mytnb login # Test login, show user info
69
+ mytnb accounts # List all linked accounts (auto-discovery)
70
+ mytnb accounts --json # Account list as JSON
61
71
  mytnb usage <account> # Monthly usage & billing summary
62
72
  mytnb usage --daily <account> # Daily usage breakdown
63
73
  mytnb usage --json <account> # Full usage data as JSON
@@ -78,25 +88,27 @@ mytnb init-config # Generate a starter config file
78
88
 
79
89
  myTNB uses two API backends, both handled transparently by this library:
80
90
 
81
- | Backend | Domain | Auth | Used for |
82
- | ----------- | --------------------- | ------------------------------------------- | ----------------------------------- |
83
- | REST | `api.mytnb.com.my` | JWT + API key | Account listing, eligibility checks |
84
- | Legacy ASMX | `mytnbapp.tnb.com.my` | Encrypted payloads (AES-256-CBC + RSA-OAEP) | Usage data, billing, services |
91
+ | Backend | Domain | Auth | Used for |
92
+ | ----------- | -------------------------- | ------------------------------------------- | ----------------------------------- |
93
+ | REST | `api.mytnb.com.my` | JWT + API key | Bill eligibility, eligibility icons |
94
+ | AWS Gateway | `api.mytnb.com.my/core/api`| Encrypted payloads (AES-256-CBC + RSA-OAEP) | Account listing (auto-discovery) |
95
+ | Legacy ASMX | `mytnbapp.tnb.com.my` | Encrypted payloads (AES-256-CBC + RSA-OAEP) | Usage data, billing, services |
85
96
 
86
97
  Request encryption for the ASMX API is automatic — just pass plaintext parameters.
87
98
 
88
99
  ## Data Models
89
100
 
90
- | Model | Description |
91
- | --------------- | ---------------------------------------------------- |
92
- | `AccountUsage` | Full usage response: metrics, monthly and daily data |
93
- | `UsageMetric` | Current/average usage (kWh) |
94
- | `CostMetric` | Current/projected cost (RM) |
95
- | `BillingMonth` | Monthly billing record with tariff blocks |
96
- | `DailyUsage` | Daily consumption and cost |
97
- | `TariffBlock` | Tariff pricing block details |
98
- | `SMRAccount` | Smart Meter Reading eligibility status |
99
- | `BREligibility` | Bill rendering opt-in status |
101
+ | Model | Description |
102
+ | ----------------- | ---------------------------------------------------- |
103
+ | `CustomerAccount` | Linked account: number, owner, address, SMR status |
104
+ | `AccountUsage` | Full usage response: metrics, monthly and daily data |
105
+ | `UsageMetric` | Current/average usage (kWh) |
106
+ | `CostMetric` | Current/projected cost (RM) |
107
+ | `BillingMonth` | Monthly billing record with tariff blocks |
108
+ | `DailyUsage` | Daily consumption and cost |
109
+ | `TariffBlock` | Tariff pricing block details |
110
+ | `SMRAccount` | Smart Meter Reading eligibility status |
111
+ | `BREligibility` | Bill rendering opt-in status |
100
112
 
101
113
  ## Geographic Restrictions
102
114
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mytnb"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Python library to interface with the myTNB API (Tenaga Nasional Berhad)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -6,6 +6,7 @@ from mytnb.models import (
6
6
  AccountUsage,
7
7
  BillingMonth,
8
8
  CostMetric,
9
+ CustomerAccount,
9
10
  DailyUsage,
10
11
  Metric,
11
12
  TariffBlock,
@@ -19,6 +20,7 @@ __all__ = [
19
20
  "AccountUsage",
20
21
  "BillingMonth",
21
22
  "CostMetric",
23
+ "CustomerAccount",
22
24
  "DailyUsage",
23
25
  "Metric",
24
26
  "TariffBlock",
@@ -371,6 +371,48 @@ def init_config(output):
371
371
  console.print(f"[green]Config written to[/] {target}")
372
372
 
373
373
 
374
+ @cli.command()
375
+ @click.option("--json", "as_json", is_flag=True, help="Output full JSON.")
376
+ @click.pass_context
377
+ def accounts(ctx, as_json):
378
+ """List all linked accounts (auto-discovery)."""
379
+
380
+ async def _accounts():
381
+ client = await _get_client(ctx)
382
+ async with client:
383
+ with console.status("[bold green]Fetching accounts..."):
384
+ result = await client.get_customer_accounts()
385
+
386
+ if as_json:
387
+ _print_json(result)
388
+ return
389
+
390
+ if not result:
391
+ console.print("[yellow]No linked accounts found.[/]")
392
+ return
393
+
394
+ table = Table(title="Linked Accounts")
395
+ table.add_column("Account No", style="cyan")
396
+ table.add_column("Owner", style="bold")
397
+ table.add_column("Address")
398
+ table.add_column("SMR", justify="center")
399
+ table.add_column("Owned", justify="center")
400
+
401
+ for acc in result:
402
+ table.add_row(
403
+ acc.account_number,
404
+ acc.owner_name or "—",
405
+ acc.account_st_address or "—",
406
+ "✓" if acc.is_smart_meter else "—",
407
+ "✓" if acc.is_owned else "—",
408
+ )
409
+
410
+ console.print(table)
411
+ console.print(f"\n[dim]{len(result)} account(s) found.[/]")
412
+
413
+ _run_async(_accounts())
414
+
415
+
374
416
  def main() -> None:
375
417
  cli() # pylint: disable=no-value-for-parameter
376
418
 
@@ -9,10 +9,12 @@ import tls_client
9
9
 
10
10
  from mytnb.auth import Credentials
11
11
  from mytnb.client.auth import login
12
- from mytnb.client.config import USER_AGENT
12
+ from mytnb.client.config import AWS_API_BASE_URL, USER_AGENT, _check_http_status
13
13
  from mytnb.client.legacy import _LegacyTransport
14
14
  from mytnb.client.rest import _RestTransport
15
- from mytnb.models import AccountUsage, BREligibility, SMRAccount
15
+ from mytnb.crypto import encrypt_request
16
+ from mytnb.exceptions import APIError
17
+ from mytnb.models import AccountUsage, BREligibility, CustomerAccount, SMRAccount
16
18
 
17
19
 
18
20
  class MyTNBClient:
@@ -178,25 +180,49 @@ class MyTNBClient:
178
180
  accounts = result.get("data", [])
179
181
  return [SMRAccount.model_validate(acc) for acc in accounts]
180
182
 
181
- async def get_services(self) -> dict:
182
- """Get available services (V4)."""
183
- data = {"usrInf": self._legacy_transport.base_user_info()}
184
- return await self._legacy_transport.post("GetServicesV4", data)
183
+ async def get_customer_accounts(self) -> list[CustomerAccount]:
184
+ """Get all accounts linked to the current user.
185
185
 
186
- async def get_energy_recommendations(
187
- self,
188
- account_number: str,
189
- *,
190
- is_owner: bool = True,
191
- ) -> dict:
192
- """Get energy budget recommendations."""
186
+ This is the auto-discovery endpoint — call this first to
187
+ discover which accounts are available, then pass individual
188
+ account numbers to get_account_usage_smart(), etc.
189
+
190
+ Uses POST /v3/account/GetAccount via the AWS API gateway.
191
+ The payload is encrypted (same encryption as legacy ASMX)
192
+ but the response is plain JSON (no ``{"d":{...}}`` wrapper).
193
+ """
194
+ usr_inf = self._legacy_transport.base_user_info()
195
+ usr_inf["IsWhiteList"] = False
193
196
  data = {
194
- "contractAccount": account_number,
195
- "isOwner": "true" if is_owner else "false",
196
- "usrInf": self._legacy_transport.base_user_info(),
197
+ "usrInf": usr_inf,
198
+ "deviceInf": self._legacy_transport.base_device_info(),
199
+ "featureInfo": [],
197
200
  }
198
- result = await self._legacy_transport.post("GetUserEBRecommendations", data)
199
- return result.get("data") or result
201
+
202
+ payload = encrypt_request(data, use_staging_key=self._use_staging_key)
203
+ body = {"dt": payload.to_dict()}
204
+
205
+ response = await self._client.post(
206
+ f"{AWS_API_BASE_URL}/v3/account/GetAccount",
207
+ headers={
208
+ "Content-Type": "application/json",
209
+ "Accept": "application/json,text/json,text/x-json,text/javascript,application/xml,text/xml",
210
+ "User-Agent": USER_AGENT,
211
+ },
212
+ json=body,
213
+ )
214
+
215
+ _check_http_status(response.status_code, context="AWS account API")
216
+
217
+ if response.status_code != 200:
218
+ raise APIError(
219
+ message=f"AWS account API request failed with status {response.status_code}",
220
+ error_code=str(response.status_code),
221
+ )
222
+
223
+ data = response.json()
224
+ accounts = data.get("data", [])
225
+ return [CustomerAccount.model_validate(acc) for acc in accounts]
200
226
 
201
227
  async def get_account_due_amount(
202
228
  self,
@@ -9,6 +9,7 @@ from mytnb.exceptions import AuthenticationError, GeoBlockedError, RateLimitErro
9
9
  # Base URLs
10
10
  LEGACY_BASE_URL = "https://mytnbapp.tnb.com.my/v7/mytnbws.asmx"
11
11
  REST_BASE_URL = "https://api.mytnb.com.my"
12
+ AWS_API_BASE_URL = "https://api.mytnb.com.my/core/api"
12
13
  SITECORE_LOGIN_URL = "https://www.mytnb.com.my/api/sitecore/Account/Login"
13
14
  SSO_HANDLER_URL = "https://myaccount.mytnb.com.my/SSO/SSOHandler"
14
15
 
@@ -74,6 +74,13 @@ class _LegacyTransport:
74
74
  "ses_param2": "",
75
75
  }
76
76
 
77
+ def base_device_info(self) -> dict:
78
+ """Build the deviceInf object from credentials for legacy ASMX requests."""
79
+ di = self._credentials.device_info
80
+ if not di:
81
+ return {}
82
+ return di.to_dict()
83
+
77
84
  async def post(self, endpoint: str, data: Any) -> dict:
78
85
  """Make a POST request to the legacy ASMX API.
79
86
 
@@ -2,9 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Optional
5
+ from typing import Any, Optional
6
6
 
7
- from pydantic import BaseModel, Field
7
+ from pydantic import AliasChoices, BaseModel, Field, field_validator
8
8
 
9
9
 
10
10
  class TariffBlock(BaseModel):
@@ -234,3 +234,105 @@ class BREligibility(BaseModel):
234
234
  is_tenant_already_opt_in: bool = Field(default=False, alias="isTenantAlreadyOptIn")
235
235
 
236
236
  model_config = {"populate_by_name": True}
237
+
238
+
239
+ class CustomerAccount(BaseModel):
240
+ """A linked/customer account returned by the account auto-discovery endpoint.
241
+
242
+ Uses GET /v3/account/GetAccount via the AWS API gateway.
243
+ Returns all accounts linked to the current user without needing
244
+ to know account numbers ahead of time.
245
+ """
246
+
247
+ account_number: str = Field(alias="accNum")
248
+ user_account_id: str = Field(
249
+ default="",
250
+ validation_alias=AliasChoices("userAccountId", "userAccountID"),
251
+ )
252
+ account_desc: str = Field(default="", alias="accDesc")
253
+ ic_num: str = Field(default="", alias="icNum")
254
+ current_charges: str = Field(default="", alias="amCurrentChg")
255
+ is_registered: str = Field(default="", alias="isRegistered")
256
+ is_paid: str = Field(default="", alias="isPaid")
257
+ is_owned: str = Field(default="", alias="isOwned")
258
+ is_error: str = Field(default="", alias="isError")
259
+ message: Optional[str] = Field(default=None, alias="message")
260
+ account_type_id: str = Field(default="", alias="accountTypeId")
261
+ account_st_address: str = Field(default="", alias="accountStAddress")
262
+ owner_name: str = Field(default="", alias="ownerName")
263
+ account_category_id: str = Field(default="", alias="accountCategoryId")
264
+ smart_meter_code: str = Field(
265
+ default="",
266
+ validation_alias=AliasChoices("smartMeterCode", "SmartMeterCode"),
267
+ )
268
+ is_tagged_smr: str = Field(default="", alias="isTaggedSMR")
269
+ is_have_access: bool = Field(default=False, alias="IsHaveAccess")
270
+ is_apply_ebilling: bool = Field(default=False, alias="IsApplyEBilling")
271
+ budget_amount: str = Field(default="", alias="BudgetAmount")
272
+ installation_type: str = Field(default="", alias="InstallationType")
273
+ created_date: str = Field(default="", alias="CreatedDate")
274
+ business_area: str = Field(default="", alias="BusinessArea")
275
+ rate_category: str = Field(default="", alias="RateCategory")
276
+
277
+ model_config = {"populate_by_name": True, "extra": "ignore"}
278
+
279
+ # -- Coerce fields that can arrive as null, numbers, or string bools --
280
+
281
+ @field_validator(
282
+ "account_desc",
283
+ "ic_num",
284
+ "current_charges",
285
+ "budget_amount",
286
+ "account_type_id",
287
+ "account_st_address",
288
+ "owner_name",
289
+ "account_category_id",
290
+ "smart_meter_code",
291
+ "installation_type",
292
+ "created_date",
293
+ "business_area",
294
+ "rate_category",
295
+ "message",
296
+ "is_registered",
297
+ "is_paid",
298
+ "is_owned",
299
+ "is_error",
300
+ "is_tagged_smr",
301
+ mode="before",
302
+ )
303
+ @classmethod
304
+ def _coerce_to_str(cls, v: Any) -> str:
305
+ """Normalise values that may arrive as null, int, float, or bool."""
306
+ if v is None:
307
+ return ""
308
+ if isinstance(v, bool):
309
+ return str(v)
310
+ if isinstance(v, (int, float)):
311
+ return str(v)
312
+ return str(v)
313
+
314
+ # -- Helpers ----------------------------------------------------------
315
+
316
+ @property
317
+ def is_smart_meter(self) -> bool:
318
+ """Whether this account has a smart meter."""
319
+ # pylint: disable=no-member
320
+ return self.is_tagged_smr.lower() == "true"
321
+
322
+ @property
323
+ def is_registered_bool(self) -> bool:
324
+ """isRegistered as a boolean."""
325
+ # pylint: disable=no-member
326
+ return self.is_registered.lower() == "true"
327
+
328
+ @property
329
+ def is_owned_bool(self) -> bool:
330
+ """isOwned as a boolean."""
331
+ # pylint: disable=no-member
332
+ return self.is_owned.lower() == "true"
333
+
334
+ @property
335
+ def is_paid_bool(self) -> bool:
336
+ """isPaid as a boolean."""
337
+ # pylint: disable=no-member
338
+ return self.is_paid.lower() == "true"
@@ -1,11 +1,15 @@
1
1
  """Tests for mytnb.cli module."""
2
2
 
3
+ # pylint: disable=duplicate-code
4
+
3
5
  import json
4
- from unittest.mock import patch
6
+ from unittest.mock import AsyncMock, MagicMock, patch
5
7
 
6
8
  import pytest
9
+ from click.testing import CliRunner
7
10
 
8
- from mytnb.cli import _build_credentials, _load_config, main
11
+ from mytnb.cli import _build_credentials, _load_config, cli, main
12
+ from mytnb.models import CustomerAccount
9
13
 
10
14
 
11
15
  class TestLoadConfig:
@@ -87,3 +91,57 @@ class TestInitConfig:
87
91
  with pytest.raises(SystemExit) as exc_info:
88
92
  main()
89
93
  assert exc_info.value.code != 0
94
+
95
+
96
+ class TestAccountsCommand:
97
+ """Tests for the `mytnb accounts` auto-discovery command."""
98
+
99
+ def test_json_output(self, tmp_path):
100
+ """Test accounts --json produces valid JSON."""
101
+ cfg_file = tmp_path / "config.json"
102
+ cfg_file.write_text(json.dumps({
103
+ "api_key": "k",
104
+ "authorization_token": "t",
105
+ "secure_key": "sk",
106
+ "user": {"user_name": "u@t.com", "user_id": "uid"},
107
+ "device": {"device_id": "did"},
108
+ }))
109
+
110
+ sample_acc = CustomerAccount.model_validate({"accNum": "220123456789"})
111
+ mock_client = MagicMock()
112
+ mock_client.get_customer_accounts = AsyncMock(return_value=[sample_acc])
113
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
114
+ mock_client.__aexit__ = AsyncMock(return_value=None)
115
+
116
+ with patch("mytnb.cli._get_client", AsyncMock(return_value=mock_client)):
117
+ runner = CliRunner()
118
+ result = runner.invoke(
119
+ cli,
120
+ ["-c", str(cfg_file), "accounts", "--json"],
121
+ )
122
+ assert result.exit_code == 0
123
+
124
+ def test_no_accounts(self, tmp_path):
125
+ """Test accounts shows empty message when no linked accounts."""
126
+ cfg_file = tmp_path / "config.json"
127
+ cfg_file.write_text(json.dumps({
128
+ "api_key": "k",
129
+ "authorization_token": "t",
130
+ "secure_key": "sk",
131
+ "user": {"user_name": "u@t.com", "user_id": "uid"},
132
+ "device": {"device_id": "did"},
133
+ }))
134
+
135
+ mock_client = MagicMock()
136
+ mock_client.get_customer_accounts = AsyncMock(return_value=[])
137
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
138
+ mock_client.__aexit__ = AsyncMock(return_value=None)
139
+
140
+ with patch("mytnb.cli._get_client", AsyncMock(return_value=mock_client)):
141
+ runner = CliRunner()
142
+ result = runner.invoke(
143
+ cli,
144
+ ["-c", str(cfg_file), "accounts"],
145
+ )
146
+ assert result.exit_code == 0
147
+ assert "No linked accounts" in result.output
@@ -1,6 +1,6 @@
1
1
  """Tests for mytnb.client API client."""
2
2
 
3
- # pylint: disable=protected-access
3
+ # pylint: disable=duplicate-code, protected-access
4
4
 
5
5
  import base64
6
6
  import json
@@ -318,6 +318,69 @@ class TestEndpoints:
318
318
  assert len(accounts) == 1
319
319
  assert accounts[0].is_smart_meter is True
320
320
 
321
+ @pytest.mark.asyncio
322
+ async def test_get_customer_accounts(self):
323
+ response_data = {
324
+ "data": [
325
+ {
326
+ "accNum": "220123456789",
327
+ "userAccountID": "ua-001",
328
+ "accDesc": "JALAN EXAMPLE 123",
329
+ "icNum": "900101-01-1234",
330
+ "amCurrentChg": 45.50,
331
+ "isRegistered": "True",
332
+ "isPaid": "False",
333
+ "isOwned": "True",
334
+ "isError": "false",
335
+ "message": None,
336
+ "accountTypeId": "1",
337
+ "accountStAddress": "NO 123, JALAN EXAMPLE, KL",
338
+ "ownerName": "AHMAD BIN ALI",
339
+ "accountCategoryId": "2",
340
+ "SmartMeterCode": "SMC001",
341
+ "isTaggedSMR": "true",
342
+ "IsHaveAccess": True,
343
+ "IsApplyEBilling": True,
344
+ "BudgetAmount": 150.00,
345
+ "InstallationType": "Residential",
346
+ "CreatedDate": "2024-01-15",
347
+ "BusinessArea": "KL",
348
+ "RateCategory": "Tariff A",
349
+ },
350
+ {
351
+ "accNum": "220987654321",
352
+ "userAccountID": "ua-002",
353
+ "isOwned": "False",
354
+ "isTaggedSMR": "false",
355
+ },
356
+ ],
357
+ }
358
+ async with MyTNBClient(_creds()) as client:
359
+ with patch.object(
360
+ client._client, "post", new_callable=AsyncMock,
361
+ return_value=_mock_response(response_data),
362
+ ):
363
+ result = await client.get_customer_accounts()
364
+ assert len(result) == 2
365
+ assert result[0].account_number == "220123456789"
366
+ assert result[0].owner_name == "AHMAD BIN ALI"
367
+ assert result[0].is_smart_meter is True
368
+ assert result[0].is_owned_bool is True
369
+ assert result[1].account_number == "220987654321"
370
+ assert result[1].is_smart_meter is False
371
+ assert result[1].is_owned_bool is False
372
+
373
+ @pytest.mark.asyncio
374
+ async def test_get_customer_accounts_empty(self):
375
+ response_data = {"data": []}
376
+ async with MyTNBClient(_creds()) as client:
377
+ with patch.object(
378
+ client._client, "post", new_callable=AsyncMock,
379
+ return_value=_mock_response(response_data),
380
+ ):
381
+ result = await client.get_customer_accounts()
382
+ assert result == []
383
+
321
384
  @pytest.mark.asyncio
322
385
  async def test_get_current_usage(self):
323
386
  response_data = {
@@ -1,9 +1,12 @@
1
1
  """Tests for mytnb.models data models."""
2
2
 
3
+ # pylint: disable=duplicate-code
4
+
3
5
  from mytnb.models import (
4
6
  AccountUsage,
5
7
  BillingMonth,
6
8
  BREligibility,
9
+ CustomerAccount,
7
10
  DailyUsage,
8
11
  Metric,
9
12
  MonthlyTariffBlock,
@@ -282,3 +285,139 @@ class TestBREligibility:
282
285
  e = BREligibility.model_validate({"caNo": "123"})
283
286
  assert e.is_owner_over_rule is False
284
287
  assert e.is_owner_already_opt_in is False
288
+
289
+
290
+ class TestCustomerAccount:
291
+ """Tests for the GetAccountsV4 / GetAccount response model."""
292
+
293
+ SAMPLE = {
294
+ "accNum": "220123456789",
295
+ "userAccountID": "user-abc-123",
296
+ "accDesc": "JALAN EXAMPLE 123",
297
+ "icNum": "900101-01-1234",
298
+ "amCurrentChg": 45.50,
299
+ "isRegistered": "True",
300
+ "isPaid": "False",
301
+ "isOwned": "True",
302
+ "isError": "false",
303
+ "message": None,
304
+ "accountTypeId": "1",
305
+ "accountStAddress": "NO 123, JALAN EXAMPLE, 50000 KL",
306
+ "ownerName": "AHMAD BIN ALI",
307
+ "accountCategoryId": "2",
308
+ "SmartMeterCode": "SMC001",
309
+ "isTaggedSMR": "true",
310
+ "IsHaveAccess": True,
311
+ "IsApplyEBilling": True,
312
+ "BudgetAmount": 150.00,
313
+ "InstallationType": "Residential",
314
+ "CreatedDate": "2024-01-15",
315
+ "BusinessArea": "KL",
316
+ "RateCategory": "Tariff A",
317
+ }
318
+
319
+ def test_parse_full(self):
320
+ acc = CustomerAccount.model_validate(self.SAMPLE)
321
+ assert acc.account_number == "220123456789"
322
+ assert acc.user_account_id == "user-abc-123"
323
+ assert acc.account_desc == "JALAN EXAMPLE 123"
324
+ assert acc.ic_num == "900101-01-1234"
325
+ assert acc.current_charges == "45.5"
326
+ assert acc.is_registered == "True"
327
+ assert acc.is_paid == "False"
328
+ assert acc.is_owned == "True"
329
+ assert acc.is_error == "false"
330
+ assert acc.message == ""
331
+ assert acc.account_type_id == "1"
332
+ assert acc.account_st_address == "NO 123, JALAN EXAMPLE, 50000 KL"
333
+ assert acc.owner_name == "AHMAD BIN ALI"
334
+ assert acc.account_category_id == "2"
335
+ assert acc.smart_meter_code == "SMC001"
336
+ assert acc.is_tagged_smr == "true"
337
+ assert acc.is_have_access is True
338
+ assert acc.is_apply_ebilling is True
339
+ assert acc.budget_amount == "150.0"
340
+ assert acc.installation_type == "Residential"
341
+ assert acc.created_date == "2024-01-15"
342
+ assert acc.business_area == "KL"
343
+ assert acc.rate_category == "Tariff A"
344
+
345
+ def test_is_smart_meter_true(self):
346
+ acc = CustomerAccount.model_validate(self.SAMPLE)
347
+ assert acc.is_smart_meter is True
348
+
349
+ def test_is_smart_meter_false(self):
350
+ data = {**self.SAMPLE, "isTaggedSMR": "false"}
351
+ acc = CustomerAccount.model_validate(data)
352
+ assert acc.is_smart_meter is False
353
+
354
+ def test_is_smart_meter_case_insensitive(self):
355
+ data = {**self.SAMPLE, "isTaggedSMR": "True"}
356
+ acc = CustomerAccount.model_validate(data)
357
+ assert acc.is_smart_meter is True
358
+
359
+ def test_boolean_helpers(self):
360
+ acc = CustomerAccount.model_validate(self.SAMPLE)
361
+ assert acc.is_registered_bool is True
362
+ assert acc.is_owned_bool is True
363
+ assert acc.is_paid_bool is False
364
+
365
+ def test_coerces_numeric_fields(self):
366
+ """Fields like amCurrentChg and BudgetAmount arrive as numbers."""
367
+ data = {**self.SAMPLE, "amCurrentChg": 0.0, "BudgetAmount": 0}
368
+ acc = CustomerAccount.model_validate(data)
369
+ assert acc.current_charges == "0.0"
370
+ assert acc.budget_amount == "0"
371
+
372
+ def test_coerces_bool_fields(self):
373
+ """isRegistered etc arrive as string 'True'/'False'."""
374
+ data = {**self.SAMPLE, "isRegistered": "False", "isOwned": "False"}
375
+ acc = CustomerAccount.model_validate(data)
376
+ assert acc.is_registered == "False"
377
+ assert acc.is_owned == "False"
378
+
379
+ def test_accepts_useraccount_id_camelcase(self):
380
+ """Should accept userAccountId (lowercase 'd') as well."""
381
+ data = {**self.SAMPLE}
382
+ del data["userAccountID"]
383
+ data["userAccountId"] = "ua-camel"
384
+ acc = CustomerAccount.model_validate(data)
385
+ assert acc.user_account_id == "ua-camel"
386
+
387
+ def test_accepts_smartmeter_code_camelcase(self):
388
+ """Should accept smartMeterCode (lowercase 's') as well."""
389
+ data = {**self.SAMPLE}
390
+ del data["SmartMeterCode"]
391
+ data["smartMeterCode"] = "SM002"
392
+ acc = CustomerAccount.model_validate(data)
393
+ assert acc.smart_meter_code == "SM002"
394
+
395
+ def test_minimal_fields(self):
396
+ acc = CustomerAccount.model_validate({"accNum": "220000000000"})
397
+ assert acc.account_number == "220000000000"
398
+ assert acc.user_account_id == ""
399
+ assert acc.owner_name == ""
400
+ assert acc.is_smart_meter is False
401
+
402
+ def test_ignores_extra_fields(self):
403
+ """Extra fields from the API (unitNo, building, etc.) are ignored."""
404
+ data = {**self.SAMPLE, "unitNo": "21-7", "building": "RESIDENSI"}
405
+ acc = CustomerAccount.model_validate(data)
406
+ assert acc.account_number == "220123456789" # still parses fine
407
+
408
+ def test_null_fields_become_empty_strings(self):
409
+ """API sends null for optional fields like icNum, message, etc."""
410
+ data = {
411
+ "accNum": "220123456789",
412
+ "icNum": None,
413
+ "message": None,
414
+ "accDesc": None,
415
+ "ownerName": None,
416
+ "accountStAddress": None,
417
+ }
418
+ acc = CustomerAccount.model_validate(data)
419
+ assert acc.ic_num == ""
420
+ assert acc.message == ""
421
+ assert acc.account_desc == ""
422
+ assert acc.owner_name == ""
423
+ assert acc.account_st_address == ""
@@ -567,7 +567,7 @@ wheels = [
567
567
 
568
568
  [[package]]
569
569
  name = "python-mytnb"
570
- version = "0.1.0"
570
+ version = "0.2.0"
571
571
  source = { editable = "." }
572
572
  dependencies = [
573
573
  { name = "click" },
File without changes
File without changes