orionapi 2.30.2__tar.gz → 2.31.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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: orionapi
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.31.0
|
|
4
4
|
Summary: A python interface for the Orion Advisors platform web API
|
|
5
5
|
Author: spencerogden-dsam
|
|
6
6
|
Author-email: 67068943+spencerogden-dsam@users.noreply.github.com
|
|
@@ -11,6 +11,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.12
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.13
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
14
15
|
Requires-Dist: rapidfuzz (>=3.0.0,<4.0.0)
|
|
15
16
|
Requires-Dist: requests (>=2.32.4,<3.0.0)
|
|
16
17
|
Requires-Dist: tabulate (>=0.8.9,<0.9.0)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
__version__ = "2.
|
|
1
|
+
__version__ = "2.31.0"
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import re
|
|
@@ -127,6 +127,8 @@ BILLING_RUN_FOR_TYPES = [
|
|
|
127
127
|
"SingleHHRerun",
|
|
128
128
|
"Accounts",
|
|
129
129
|
"ImportedHouseholds",
|
|
130
|
+
"ImportedHHRerun",
|
|
131
|
+
"FinalBill",
|
|
130
132
|
]
|
|
131
133
|
|
|
132
134
|
BILLING_ACCOUNT_FILTERS = ["ActiveAccounts", "InActiveAccounts", "AllAccounts"]
|
|
@@ -170,6 +172,19 @@ BILLING_BILL_TYPES = [
|
|
|
170
172
|
"AdvanceCreditDebit",
|
|
171
173
|
]
|
|
172
174
|
|
|
175
|
+
# Billing instance statusValue strings as the live API returns them. The
|
|
176
|
+
# swagger enum names differ ("BillDataFilesNeeded", "FeeFileNeeded", ...);
|
|
177
|
+
# the API sends display strings instead.
|
|
178
|
+
BILLING_STATUS_NOT_GENERATED = "Not Generated"
|
|
179
|
+
BILLING_STATUS_DATA_FILES_NEEDED = "Data Files Needed"
|
|
180
|
+
BILLING_STATUS_FEE_FILE_NEEDED = "Fee File Needed"
|
|
181
|
+
BILLING_STATUS_COMPLETE = "Complete"
|
|
182
|
+
# Instance statuses that mean something went wrong; waiting further is pointless.
|
|
183
|
+
BILLING_ERROR_STATUSES = {"Fee File Failed", "Error Deleting Instance"}
|
|
184
|
+
# Per-household (BillInstanceClient) statuses still in flight during generation.
|
|
185
|
+
BILLING_CLIENT_PENDING_STATUSES = {"NotGenerated", "PendingGeneration"}
|
|
186
|
+
|
|
187
|
+
|
|
173
188
|
# Fields of the Orion API ReceiveableSummaryDto, the body of
|
|
174
189
|
# POST /Billing/SyncCashtoEclipse. Identical to the CashFundingGridDto rows
|
|
175
190
|
# returned by get_cash_funding(), except the grid's "id" is "accountId" here.
|
|
@@ -225,6 +240,48 @@ class AmbiguousAccountError(OrionAPIError):
|
|
|
225
240
|
pass
|
|
226
241
|
|
|
227
242
|
|
|
243
|
+
class BillingGenerationError(OrionAPIError):
|
|
244
|
+
"""A billing run reached an error state (instance or household level)."""
|
|
245
|
+
|
|
246
|
+
def __init__(self, message, instance=None, errors=None):
|
|
247
|
+
super().__init__(message)
|
|
248
|
+
self.instance = instance
|
|
249
|
+
self.errors = errors or []
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _require_positive_int_list(name, values):
|
|
253
|
+
"""Raise ValueError unless ``values`` is a non-empty list of positive ints."""
|
|
254
|
+
if not isinstance(values, list) or not values:
|
|
255
|
+
raise ValueError(f"{name} must be a non-empty list")
|
|
256
|
+
for value in values:
|
|
257
|
+
if not isinstance(value, int) or value < 1:
|
|
258
|
+
raise ValueError(f"{name} must be positive integers")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _require_poll_args(timeout, poll_interval):
|
|
262
|
+
"""Raise ValueError unless ``timeout`` and ``poll_interval`` are positive numbers."""
|
|
263
|
+
if not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
264
|
+
raise ValueError("timeout must be a positive number")
|
|
265
|
+
if not isinstance(poll_interval, (int, float)) or poll_interval <= 0:
|
|
266
|
+
raise ValueError("poll_interval must be a positive number")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _squash(value):
|
|
270
|
+
"""Remove spaces, so display strings ("Pending Generation") match enum names."""
|
|
271
|
+
return (value or "").replace(" ", "")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _json_or_none(res):
|
|
275
|
+
"""Parse a JSON response body, or return None when the body is empty.
|
|
276
|
+
|
|
277
|
+
Several Orion billing actions (e.g. Generate, PostAuditFiles) answer
|
|
278
|
+
200/204 with no body.
|
|
279
|
+
"""
|
|
280
|
+
if not res.content or not res.content.strip():
|
|
281
|
+
return None
|
|
282
|
+
return res.json()
|
|
283
|
+
|
|
284
|
+
|
|
228
285
|
class RateLimiter:
|
|
229
286
|
"""Thread-safe rate limiter to prevent API abuse.
|
|
230
287
|
|
|
@@ -366,7 +423,9 @@ class BaseAPI:
|
|
|
366
423
|
**kwargs: Additional arguments passed to the request
|
|
367
424
|
|
|
368
425
|
Returns:
|
|
369
|
-
requests.Response object
|
|
426
|
+
requests.Response object. Some mutating endpoints answer with an
|
|
427
|
+
empty body; parse those with ``_json_or_none(res)`` rather than
|
|
428
|
+
``res.json()``.
|
|
370
429
|
|
|
371
430
|
Raises:
|
|
372
431
|
AuthenticationError: On 401/403 responses
|
|
@@ -1412,14 +1471,22 @@ class OrionAPI(BaseAPI):
|
|
|
1412
1471
|
as_of_date=None,
|
|
1413
1472
|
end_date_override=None,
|
|
1414
1473
|
include_cash_flow=False,
|
|
1474
|
+
allow_duplicate_mock_bills=None,
|
|
1475
|
+
value_as_of_override=None,
|
|
1476
|
+
date_range=None,
|
|
1415
1477
|
):
|
|
1416
1478
|
"""Create a new billing instance (live or forecast).
|
|
1417
1479
|
|
|
1480
|
+
The billing period is derived from ``as_of_date`` (e.g. 2026-09-30
|
|
1481
|
+
creates a 2026-10-01..2026-12-31 quarterly instance). A new instance
|
|
1482
|
+
starts in "Not Generated"; call generate_billing() to run it.
|
|
1483
|
+
|
|
1418
1484
|
Args:
|
|
1419
1485
|
is_forecast: If True, creates a forecast/mock bill (default False)
|
|
1420
1486
|
run_for: Scope of the billing run. One of: AllHouseholds, FundFamily,
|
|
1421
1487
|
Custodian, Representative, BusinessLine, SingleHHNewBill,
|
|
1422
|
-
SingleHHRerun, Accounts, ImportedHouseholds
|
|
1488
|
+
SingleHHRerun, Accounts, ImportedHouseholds, ImportedHHRerun,
|
|
1489
|
+
FinalBill
|
|
1423
1490
|
run_for_accounts: Account filter. One of: ActiveAccounts,
|
|
1424
1491
|
InActiveAccounts, AllAccounts
|
|
1425
1492
|
bill_type: Type of bill. One of: Unknown, Renewal, NewMoney,
|
|
@@ -1429,9 +1496,14 @@ class OrionAPI(BaseAPI):
|
|
|
1429
1496
|
as_of_date: Optional as-of date (YYYY-MM-DD format)
|
|
1430
1497
|
end_date_override: Optional end date override (YYYY-MM-DD format)
|
|
1431
1498
|
include_cash_flow: Whether to include cash flow (default False)
|
|
1499
|
+
allow_duplicate_mock_bills: Optional bool, sent as
|
|
1500
|
+
allowDuplicateMockBills. Orion accepted a second single-household
|
|
1501
|
+
forecast for an already-forecast period without it (2026-09).
|
|
1502
|
+
value_as_of_override: Optional valuation date override (YYYY-MM-DD)
|
|
1503
|
+
date_range: Optional list of dates, sent as-is as dateRange
|
|
1432
1504
|
|
|
1433
1505
|
Returns:
|
|
1434
|
-
dict: Created billing instance details
|
|
1506
|
+
dict: Created billing instance details (BillInstanceDto)
|
|
1435
1507
|
"""
|
|
1436
1508
|
if run_for not in BILLING_RUN_FOR_TYPES:
|
|
1437
1509
|
raise ValueError(f"run_for must be one of {BILLING_RUN_FOR_TYPES}")
|
|
@@ -1445,6 +1517,9 @@ class OrionAPI(BaseAPI):
|
|
|
1445
1517
|
if keys is not None and not isinstance(keys, list):
|
|
1446
1518
|
raise ValueError("keys must be a list of integers")
|
|
1447
1519
|
|
|
1520
|
+
if date_range is not None and not isinstance(date_range, list):
|
|
1521
|
+
raise ValueError("date_range must be a list")
|
|
1522
|
+
|
|
1448
1523
|
payload = {
|
|
1449
1524
|
"isMockBill": is_forecast,
|
|
1450
1525
|
"runFor": run_for,
|
|
@@ -1460,6 +1535,12 @@ class OrionAPI(BaseAPI):
|
|
|
1460
1535
|
payload["asOfDate"] = as_of_date
|
|
1461
1536
|
if end_date_override is not None:
|
|
1462
1537
|
payload["endDateOverride"] = end_date_override
|
|
1538
|
+
if allow_duplicate_mock_bills is not None:
|
|
1539
|
+
payload["allowDuplicateMockBills"] = allow_duplicate_mock_bills
|
|
1540
|
+
if value_as_of_override is not None:
|
|
1541
|
+
payload["valueAsOfOverride"] = value_as_of_override
|
|
1542
|
+
if date_range is not None:
|
|
1543
|
+
payload["dateRange"] = date_range
|
|
1463
1544
|
|
|
1464
1545
|
res = self.api_request(
|
|
1465
1546
|
f"{self.base_url}/Billing/BillGenerator/Action/Instance",
|
|
@@ -1468,25 +1549,39 @@ class OrionAPI(BaseAPI):
|
|
|
1468
1549
|
)
|
|
1469
1550
|
return res.json()
|
|
1470
1551
|
|
|
1471
|
-
def generate_billing(self, instance_id, lock_down=True):
|
|
1472
|
-
"""
|
|
1552
|
+
def generate_billing(self, instance_id, lock_down=True, ids=None):
|
|
1553
|
+
"""Start bill generation for a billing instance.
|
|
1554
|
+
|
|
1555
|
+
Generation runs in the background and returns immediately with an
|
|
1556
|
+
empty body. Use wait_for_billing_instance() to block until it finishes.
|
|
1473
1557
|
|
|
1474
1558
|
Args:
|
|
1475
1559
|
instance_id: Billing instance ID
|
|
1476
1560
|
lock_down: Whether to lock down the instance during generation (default True)
|
|
1561
|
+
ids: Optional list of household-record IDs to limit the run to:
|
|
1562
|
+
the ``id`` of get_billing_instance_clients() rows, NOT client
|
|
1563
|
+
IDs (a client ID gets 404 "No available bills found"). Sent as
|
|
1564
|
+
a bare int array body. Omitted, no body is sent and the whole
|
|
1565
|
+
instance is generated. To rerun one household, delete its bill
|
|
1566
|
+
(delete_bills) and pass its record id here.
|
|
1477
1567
|
|
|
1478
1568
|
Returns:
|
|
1479
|
-
dict:
|
|
1569
|
+
dict | None: Parsed response, or None when Orion returns an empty
|
|
1570
|
+
body (the normal case).
|
|
1480
1571
|
"""
|
|
1481
1572
|
if not isinstance(instance_id, int) or instance_id < 1:
|
|
1482
1573
|
raise ValueError("instance_id must be a positive integer")
|
|
1574
|
+
if ids is not None and (not isinstance(ids, list) or not ids):
|
|
1575
|
+
raise ValueError("ids must be a non-empty list of integers")
|
|
1483
1576
|
|
|
1484
1577
|
params = urlencode({"lockDown": str(lock_down).lower()})
|
|
1578
|
+
kwargs = {"json": ids} if ids is not None else {}
|
|
1485
1579
|
res = self.api_request(
|
|
1486
1580
|
f"{self.base_url}/Billing/Instances/{instance_id}/Action/Generate?{params}",
|
|
1487
1581
|
requests.put,
|
|
1582
|
+
**kwargs,
|
|
1488
1583
|
)
|
|
1489
|
-
return res
|
|
1584
|
+
return _json_or_none(res)
|
|
1490
1585
|
|
|
1491
1586
|
def complete_billing_instance(self, instance_id):
|
|
1492
1587
|
"""Finalize/complete a billing instance.
|
|
@@ -1506,23 +1601,24 @@ class OrionAPI(BaseAPI):
|
|
|
1506
1601
|
)
|
|
1507
1602
|
return res.json()
|
|
1508
1603
|
|
|
1509
|
-
def invalidate_billing_instance(self, instance_id):
|
|
1604
|
+
def invalidate_billing_instance(self, instance_id, validate=None):
|
|
1510
1605
|
"""Invalidate/cancel a billing instance.
|
|
1511
1606
|
|
|
1512
1607
|
Args:
|
|
1513
1608
|
instance_id: Billing instance ID
|
|
1609
|
+
validate: Optional bool, sent as the ``validate`` query param
|
|
1514
1610
|
|
|
1515
1611
|
Returns:
|
|
1516
|
-
dict: Updated billing instance
|
|
1612
|
+
dict | None: Updated billing instance, or None on an empty body
|
|
1517
1613
|
"""
|
|
1518
1614
|
if not isinstance(instance_id, int) or instance_id < 1:
|
|
1519
1615
|
raise ValueError("instance_id must be a positive integer")
|
|
1520
1616
|
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
)
|
|
1525
|
-
return res
|
|
1617
|
+
url = f"{self.base_url}/Billing/Instances/{instance_id}/Action/Invalidate"
|
|
1618
|
+
if validate is not None:
|
|
1619
|
+
url += "?" + urlencode({"validate": str(bool(validate)).lower()})
|
|
1620
|
+
res = self.api_request(url, requests.post)
|
|
1621
|
+
return _json_or_none(res)
|
|
1526
1622
|
|
|
1527
1623
|
def generate_fee_files(self, instance_id, custodian_id=None):
|
|
1528
1624
|
"""Generate fee files for a billing instance.
|
|
@@ -1531,8 +1627,12 @@ class OrionAPI(BaseAPI):
|
|
|
1531
1627
|
instance_id: Billing instance ID
|
|
1532
1628
|
custodian_id: Optional custodian ID to filter fee files
|
|
1533
1629
|
|
|
1630
|
+
The instance must be in "Fee File Needed" status; otherwise Orion
|
|
1631
|
+
returns 400 "You cannot generate fee file. The status needs to be Fee
|
|
1632
|
+
File Needed".
|
|
1633
|
+
|
|
1534
1634
|
Returns:
|
|
1535
|
-
dict: Fee file generation result
|
|
1635
|
+
dict | None: Fee file generation result, or None on an empty body
|
|
1536
1636
|
"""
|
|
1537
1637
|
if not isinstance(instance_id, int) or instance_id < 1:
|
|
1538
1638
|
raise ValueError("instance_id must be a positive integer")
|
|
@@ -1543,8 +1643,10 @@ class OrionAPI(BaseAPI):
|
|
|
1543
1643
|
raise ValueError("custodian_id must be a positive integer")
|
|
1544
1644
|
url += "?" + urlencode({"custodianId": custodian_id})
|
|
1545
1645
|
|
|
1546
|
-
|
|
1547
|
-
|
|
1646
|
+
# Bare int array. Live-verified 2026-09: {"ids": [...]} gets a generic
|
|
1647
|
+
# 501 "An error has occurred"; the bare array reaches Orion's status check.
|
|
1648
|
+
res = self.api_request(url, requests.post, json=[instance_id])
|
|
1649
|
+
return _json_or_none(res)
|
|
1548
1650
|
|
|
1549
1651
|
def get_fee_files(self, instance_id):
|
|
1550
1652
|
"""Get fee files for a billing instance.
|
|
@@ -1561,6 +1663,318 @@ class OrionAPI(BaseAPI):
|
|
|
1561
1663
|
res = self.api_request(f"{self.base_url}/Billing/FeeFile/instance/{instance_id}")
|
|
1562
1664
|
return res.json()
|
|
1563
1665
|
|
|
1666
|
+
def get_billing_instance_clients(self, instance_id):
|
|
1667
|
+
"""Get the per-household records of a billing instance.
|
|
1668
|
+
|
|
1669
|
+
Each record carries the household's generation ``status`` (one of
|
|
1670
|
+
NotGenerated, Generated, Errored, PendingGeneration, OnHold, Warning,
|
|
1671
|
+
FeeFileError, ReconError), ``errorMessage``, ``billId`` and amounts.
|
|
1672
|
+
This is where generation errors show up; the instance's own
|
|
1673
|
+
statusValue has no generation-error state.
|
|
1674
|
+
|
|
1675
|
+
Args:
|
|
1676
|
+
instance_id: Billing instance ID
|
|
1677
|
+
|
|
1678
|
+
Returns:
|
|
1679
|
+
list: BillInstanceClient records
|
|
1680
|
+
"""
|
|
1681
|
+
if not isinstance(instance_id, int) or instance_id < 1:
|
|
1682
|
+
raise ValueError("instance_id must be a positive integer")
|
|
1683
|
+
|
|
1684
|
+
res = self.api_request(
|
|
1685
|
+
f"{self.base_url}/Billing/BillGenerator/Instance/{instance_id}/ClientList"
|
|
1686
|
+
)
|
|
1687
|
+
return res.json()
|
|
1688
|
+
|
|
1689
|
+
def wait_for_billing_instance(
|
|
1690
|
+
self,
|
|
1691
|
+
instance_id,
|
|
1692
|
+
target_statuses=(BILLING_STATUS_DATA_FILES_NEEDED,),
|
|
1693
|
+
timeout=600,
|
|
1694
|
+
poll_interval=10,
|
|
1695
|
+
raise_on_client_errors=True,
|
|
1696
|
+
):
|
|
1697
|
+
"""Block until a billing instance reaches one of ``target_statuses``.
|
|
1698
|
+
|
|
1699
|
+
Generation has no job id, so the only completion signal is the
|
|
1700
|
+
instance's statusValue plus the per-household statuses. A generated
|
|
1701
|
+
instance moves from "Not Generated" to "Data Files Needed", which is
|
|
1702
|
+
the default target (a one-household forecast took ~15s in 2026-09).
|
|
1703
|
+
On a rerun the instance can already sit at "Data Files Needed", so the
|
|
1704
|
+
wait also requires that no household is still "Not Generated" /
|
|
1705
|
+
"Pending Generation". Call it right after generate_billing(). The live
|
|
1706
|
+
API returns display strings ("Data Files Needed"), not the swagger's
|
|
1707
|
+
enum names ("BillDataFilesNeeded").
|
|
1708
|
+
|
|
1709
|
+
Args:
|
|
1710
|
+
instance_id: Billing instance ID
|
|
1711
|
+
target_statuses: statusValue strings that count as done
|
|
1712
|
+
timeout: Wall-clock timeout in seconds (default 600)
|
|
1713
|
+
poll_interval: Seconds between polls (default 10)
|
|
1714
|
+
raise_on_client_errors: If True (default), once done, raise if any
|
|
1715
|
+
household has status "Errored".
|
|
1716
|
+
"Warning" (e.g. "No receivables with value.") is not an error.
|
|
1717
|
+
|
|
1718
|
+
Returns:
|
|
1719
|
+
dict: The final billing instance
|
|
1720
|
+
|
|
1721
|
+
Raises:
|
|
1722
|
+
ValueError: on invalid args
|
|
1723
|
+
BillingGenerationError: if the instance enters an error status
|
|
1724
|
+
("Fee File Failed", "Error Deleting Instance"); if every
|
|
1725
|
+
household finished, some errored, and the instance never
|
|
1726
|
+
reached a target; or (with raise_on_client_errors) if the
|
|
1727
|
+
target is reached with errored households. ``.errors`` holds
|
|
1728
|
+
the errored household records.
|
|
1729
|
+
TimeoutError: if no target status is reached within ``timeout``
|
|
1730
|
+
"""
|
|
1731
|
+
if not isinstance(instance_id, int) or instance_id < 1:
|
|
1732
|
+
raise ValueError("instance_id must be a positive integer")
|
|
1733
|
+
if isinstance(target_statuses, str):
|
|
1734
|
+
target_statuses = (target_statuses,)
|
|
1735
|
+
if not target_statuses:
|
|
1736
|
+
raise ValueError("target_statuses must be a non-empty list of statuses")
|
|
1737
|
+
_require_poll_args(timeout, poll_interval)
|
|
1738
|
+
|
|
1739
|
+
deadline = time.monotonic() + timeout
|
|
1740
|
+
while True:
|
|
1741
|
+
instance = self.get_billing_instance(instance_id)
|
|
1742
|
+
status = instance.get("statusValue")
|
|
1743
|
+
|
|
1744
|
+
if status in BILLING_ERROR_STATUSES:
|
|
1745
|
+
raise BillingGenerationError(
|
|
1746
|
+
f"Billing instance {instance_id} is in error status {status!r}",
|
|
1747
|
+
instance=instance,
|
|
1748
|
+
)
|
|
1749
|
+
|
|
1750
|
+
# Household statuses come back as display strings ("Pending
|
|
1751
|
+
# Generation"); compare with spaces removed.
|
|
1752
|
+
clients = self.get_billing_instance_clients(instance_id)
|
|
1753
|
+
pending = any(
|
|
1754
|
+
_squash(c.get("status")) in BILLING_CLIENT_PENDING_STATUSES for c in clients
|
|
1755
|
+
)
|
|
1756
|
+
errors = [c for c in clients if _squash(c.get("status")) == "Errored"]
|
|
1757
|
+
|
|
1758
|
+
done = status in target_statuses
|
|
1759
|
+
# Raise on errored households once none are pending: at the target
|
|
1760
|
+
# (unless told not to), or when the instance never got there and
|
|
1761
|
+
# further polling won't help.
|
|
1762
|
+
if not pending and errors and (raise_on_client_errors or not done):
|
|
1763
|
+
verb = "reached" if done else "stayed"
|
|
1764
|
+
raise BillingGenerationError(
|
|
1765
|
+
f"Billing instance {instance_id} {verb} {status!r} with "
|
|
1766
|
+
f"{len(errors)} errored household(s)",
|
|
1767
|
+
instance=instance,
|
|
1768
|
+
errors=errors,
|
|
1769
|
+
)
|
|
1770
|
+
if not pending and done:
|
|
1771
|
+
return instance
|
|
1772
|
+
|
|
1773
|
+
if time.monotonic() >= deadline:
|
|
1774
|
+
raise TimeoutError(
|
|
1775
|
+
f"Billing instance {instance_id} still {status!r} after {timeout}s; "
|
|
1776
|
+
f"waiting for one of {list(target_statuses)}"
|
|
1777
|
+
)
|
|
1778
|
+
time.sleep(poll_interval)
|
|
1779
|
+
|
|
1780
|
+
def cancel_billing_generation(self, instance_id):
|
|
1781
|
+
"""Cancel a running bill generation job.
|
|
1782
|
+
|
|
1783
|
+
Not live-verified: a one-household forecast finishes generating in
|
|
1784
|
+
~15s, before there is anything to cancel.
|
|
1785
|
+
|
|
1786
|
+
Args:
|
|
1787
|
+
instance_id: Billing instance ID
|
|
1788
|
+
|
|
1789
|
+
Returns:
|
|
1790
|
+
dict | None: Parsed response, or None on an empty body
|
|
1791
|
+
"""
|
|
1792
|
+
if not isinstance(instance_id, int) or instance_id < 1:
|
|
1793
|
+
raise ValueError("instance_id must be a positive integer")
|
|
1794
|
+
|
|
1795
|
+
res = self.api_request(
|
|
1796
|
+
f"{self.base_url}/Billing/Instances/{instance_id}/Action/Generate/Cancel",
|
|
1797
|
+
requests.put,
|
|
1798
|
+
)
|
|
1799
|
+
return _json_or_none(res)
|
|
1800
|
+
|
|
1801
|
+
def delete_billing_instances(self, instance_ids, forecast_only=True):
|
|
1802
|
+
"""Delete billing instances. Irreversible.
|
|
1803
|
+
|
|
1804
|
+
Deletion is asynchronous but quick: verified 2026-09 on a
|
|
1805
|
+
one-household forecast, the instance read "Deletion In Progress"
|
|
1806
|
+
immediately and was gone within ~5s, after which
|
|
1807
|
+
get_billing_instance() raises OrionAPIError 400 "Unable to find Bill
|
|
1808
|
+
Instance" (not NotFoundError/404). "Error Deleting Instance" is the
|
|
1809
|
+
failure status.
|
|
1810
|
+
|
|
1811
|
+
The instance's audit files are NOT deleted with it; remove them first
|
|
1812
|
+
with get_audit_files(instance_id=...) and delete_audit_file().
|
|
1813
|
+
|
|
1814
|
+
Args:
|
|
1815
|
+
instance_ids: List of billing instance IDs
|
|
1816
|
+
forecast_only: If True (default), fetch each instance first and
|
|
1817
|
+
refuse (ValueError, nothing deleted) unless every one is a
|
|
1818
|
+
forecast (``isMockBill``). ``instanceType`` is not used for
|
|
1819
|
+
this check because it reads "Live" on a forecast until the
|
|
1820
|
+
forecast has been generated. Pass False to delete live
|
|
1821
|
+
instances.
|
|
1822
|
+
|
|
1823
|
+
Returns:
|
|
1824
|
+
list: IDs Orion accepted for deletion
|
|
1825
|
+
"""
|
|
1826
|
+
_require_positive_int_list("instance_ids", instance_ids)
|
|
1827
|
+
|
|
1828
|
+
if forecast_only:
|
|
1829
|
+
live = [i for i in instance_ids if not self.get_billing_instance(i).get("isMockBill")]
|
|
1830
|
+
if live:
|
|
1831
|
+
raise ValueError(
|
|
1832
|
+
f"Refusing to delete non-forecast billing instance(s) {live}; "
|
|
1833
|
+
"pass forecast_only=False to delete live instances"
|
|
1834
|
+
)
|
|
1835
|
+
|
|
1836
|
+
res = self.api_request(
|
|
1837
|
+
f"{self.base_url}/Billing/Instances/Action/Delete",
|
|
1838
|
+
requests.put,
|
|
1839
|
+
json=instance_ids,
|
|
1840
|
+
)
|
|
1841
|
+
return _json_or_none(res)
|
|
1842
|
+
|
|
1843
|
+
def generate_audit_files(self, instance_ids, start_date=None, end_date=None, entity_list=None):
|
|
1844
|
+
"""Generate the billing audit files for billing instance(s).
|
|
1845
|
+
|
|
1846
|
+
Works on forecast instances. For one instance Orion produced three
|
|
1847
|
+
files within ~10s: "billing audit.CSV", "billing data.XLSX" and
|
|
1848
|
+
"payable summary data.XLSX". The call returns before they exist; poll
|
|
1849
|
+
get_audit_files() to see them.
|
|
1850
|
+
|
|
1851
|
+
Args:
|
|
1852
|
+
instance_ids: List of billing instance IDs (sent to Orion as a
|
|
1853
|
+
comma-separated string, billInstanceIds)
|
|
1854
|
+
start_date: Optional start of the instance date range (YYYY-MM-DD)
|
|
1855
|
+
end_date: Optional end of the instance date range (YYYY-MM-DD)
|
|
1856
|
+
entity_list: Optional list of payable entities to filter accounts on
|
|
1857
|
+
|
|
1858
|
+
Returns:
|
|
1859
|
+
None: Orion answers 204 No Content
|
|
1860
|
+
"""
|
|
1861
|
+
_require_positive_int_list("instance_ids", instance_ids)
|
|
1862
|
+
|
|
1863
|
+
# billData/payableSummary/billingAudit are marked UNUSED in the spec.
|
|
1864
|
+
payload = {"billInstanceIds": ",".join(str(i) for i in instance_ids)}
|
|
1865
|
+
if start_date is not None:
|
|
1866
|
+
payload["startDate"] = start_date
|
|
1867
|
+
if end_date is not None:
|
|
1868
|
+
payload["endDate"] = end_date
|
|
1869
|
+
if entity_list is not None:
|
|
1870
|
+
payload["entityList"] = entity_list
|
|
1871
|
+
|
|
1872
|
+
res = self.api_request(
|
|
1873
|
+
f"{self.base_url}/Billing/PostAuditFiles", requests.post, json=payload
|
|
1874
|
+
)
|
|
1875
|
+
return _json_or_none(res)
|
|
1876
|
+
|
|
1877
|
+
def get_audit_files(self, instance_id=None, start_date=None, end_date=None):
|
|
1878
|
+
"""List billing audit files.
|
|
1879
|
+
|
|
1880
|
+
``blobText`` is null in the listing; fetch content with
|
|
1881
|
+
download_audit_file(file["id"]).
|
|
1882
|
+
|
|
1883
|
+
Args:
|
|
1884
|
+
instance_id: Optional billing instance ID to filter by
|
|
1885
|
+
start_date: Optional start date (YYYY-MM-DD)
|
|
1886
|
+
end_date: Optional end date (YYYY-MM-DD)
|
|
1887
|
+
|
|
1888
|
+
Returns:
|
|
1889
|
+
list: AuditFileBlobDto records (id, instanceId, fileName, blobDesc,
|
|
1890
|
+
openWith, isZip, createdDate, startDate, endDate, ...)
|
|
1891
|
+
"""
|
|
1892
|
+
params = {}
|
|
1893
|
+
if instance_id is not None:
|
|
1894
|
+
if not isinstance(instance_id, int) or instance_id < 1:
|
|
1895
|
+
raise ValueError("instance_id must be a positive integer")
|
|
1896
|
+
params["billInstanceId"] = instance_id
|
|
1897
|
+
if start_date is not None:
|
|
1898
|
+
params["startDate"] = start_date
|
|
1899
|
+
if end_date is not None:
|
|
1900
|
+
params["endDate"] = end_date
|
|
1901
|
+
|
|
1902
|
+
url = f"{self.base_url}/Billing/Audit/AuditFiles"
|
|
1903
|
+
if params:
|
|
1904
|
+
url += "?" + urlencode(params)
|
|
1905
|
+
res = self.api_request(url)
|
|
1906
|
+
return res.json()
|
|
1907
|
+
|
|
1908
|
+
def download_audit_file(self, file_id):
|
|
1909
|
+
"""Download one billing audit file.
|
|
1910
|
+
|
|
1911
|
+
Args:
|
|
1912
|
+
file_id: Audit file ID (``id`` from get_audit_files)
|
|
1913
|
+
|
|
1914
|
+
Returns:
|
|
1915
|
+
dict: ``content`` (bytes), ``filename`` (from Content-Disposition,
|
|
1916
|
+
or None) and ``content_type``. CSVs come back as
|
|
1917
|
+
application/octet-stream, XLSX as the OpenXML spreadsheet type.
|
|
1918
|
+
"""
|
|
1919
|
+
if not isinstance(file_id, int) or file_id < 1:
|
|
1920
|
+
raise ValueError("file_id must be a positive integer")
|
|
1921
|
+
|
|
1922
|
+
res = self.api_request(f"{self.base_url}/Billing/Audit/{file_id}/File")
|
|
1923
|
+
disposition = res.headers.get("Content-Disposition") or ""
|
|
1924
|
+
match = re.search(r'filename="?([^";]+)"?', disposition)
|
|
1925
|
+
return {
|
|
1926
|
+
"content": res.content,
|
|
1927
|
+
"filename": match.group(1) if match else None,
|
|
1928
|
+
"content_type": res.headers.get("Content-Type"),
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
def delete_audit_file(self, file_id):
|
|
1932
|
+
"""Delete one billing audit file.
|
|
1933
|
+
|
|
1934
|
+
Args:
|
|
1935
|
+
file_id: Audit file ID (``id`` from get_audit_files)
|
|
1936
|
+
|
|
1937
|
+
Returns:
|
|
1938
|
+
None: Orion answers with an empty body (verified 2026-09)
|
|
1939
|
+
"""
|
|
1940
|
+
if not isinstance(file_id, int) or file_id < 1:
|
|
1941
|
+
raise ValueError("file_id must be a positive integer")
|
|
1942
|
+
|
|
1943
|
+
res = self.api_request(f"{self.base_url}/Billing/Audit/{file_id}/File", requests.delete)
|
|
1944
|
+
return _json_or_none(res)
|
|
1945
|
+
|
|
1946
|
+
def get_bill_data_export(self, instance_id=None, start_date=None, end_date=None):
|
|
1947
|
+
"""Get the bill data export (Orion: "list of unpaid bills").
|
|
1948
|
+
|
|
1949
|
+
Returns unpaid live bills only. Verified 2026-09: it returns [] for a
|
|
1950
|
+
generated forecast instance, so for forecast rows use get_bills(
|
|
1951
|
+
instance_id=...) or the "billing data.XLSX" audit file
|
|
1952
|
+
(generate_audit_files / download_audit_file) instead.
|
|
1953
|
+
|
|
1954
|
+
Args:
|
|
1955
|
+
instance_id: Optional billing instance ID to filter by
|
|
1956
|
+
start_date: Optional start date (YYYY-MM-DD)
|
|
1957
|
+
end_date: Optional end date (YYYY-MM-DD)
|
|
1958
|
+
|
|
1959
|
+
Returns:
|
|
1960
|
+
list: BillDataDto records
|
|
1961
|
+
"""
|
|
1962
|
+
params = {}
|
|
1963
|
+
if instance_id is not None:
|
|
1964
|
+
if not isinstance(instance_id, int) or instance_id < 1:
|
|
1965
|
+
raise ValueError("instance_id must be a positive integer")
|
|
1966
|
+
params["billInstanceId"] = instance_id
|
|
1967
|
+
if start_date is not None:
|
|
1968
|
+
params["startDate"] = start_date
|
|
1969
|
+
if end_date is not None:
|
|
1970
|
+
params["endDate"] = end_date
|
|
1971
|
+
|
|
1972
|
+
url = f"{self.base_url}/Billing/BillDataExport"
|
|
1973
|
+
if params:
|
|
1974
|
+
url += "?" + urlencode(params)
|
|
1975
|
+
res = self.api_request(url)
|
|
1976
|
+
return res.json()
|
|
1977
|
+
|
|
1564
1978
|
def get_bills(self, instance_id=None, is_valid=None, bill_type=None):
|
|
1565
1979
|
"""Get bills, optionally filtered.
|
|
1566
1980
|
|
|
@@ -1778,8 +2192,17 @@ class OrionAPI(BaseAPI):
|
|
|
1778
2192
|
"""Export one cash funding row to Eclipse as a cash set-aside.
|
|
1779
2193
|
|
|
1780
2194
|
This is the API equivalent of the Cash Funding grid's export action:
|
|
1781
|
-
Orion creates
|
|
1782
|
-
|
|
2195
|
+
Orion creates a set-aside in Eclipse for the account's balance due,
|
|
2196
|
+
so the fee cash is reserved from trading.
|
|
2197
|
+
|
|
2198
|
+
Every call INSERTS a new set-aside; it never updates an existing one.
|
|
2199
|
+
Live-verified 2026-09: two calls for the same account left two active
|
|
2200
|
+
set-asides. Each is a dollar amount with description "OC to Eclipse
|
|
2201
|
+
Sync" and expiration type "None" (it never expires on its own). To
|
|
2202
|
+
re-export, first retire the account's earlier active "OC to Eclipse
|
|
2203
|
+
Sync" set-asides. Prefer Eclipse expire_set_asides(), which keeps them
|
|
2204
|
+
as inactive history, over delete_account_set_aside_cash(), which
|
|
2205
|
+
removes them.
|
|
1783
2206
|
|
|
1784
2207
|
Orion documents this as a draft endpoint that takes a single account
|
|
1785
2208
|
per call, so exporting a whole report means looping over the rows of
|
|
@@ -1835,7 +2258,9 @@ class OrionAPI(BaseAPI):
|
|
|
1835
2258
|
if delete_related_households:
|
|
1836
2259
|
url += "?" + urlencode({"deleteRelatedHouseholds": "true"})
|
|
1837
2260
|
|
|
1838
|
-
|
|
2261
|
+
# Bare int array. Live-verified 2026-09: {"ids": [...]} gets 400
|
|
2262
|
+
# "Bill Ids are missing."; the bare array deletes and echoes the ids.
|
|
2263
|
+
res = self.api_request(url, requests.put, json=bill_ids)
|
|
1839
2264
|
return res.json()
|
|
1840
2265
|
|
|
1841
2266
|
# -------------------------------------------------------------------------
|
|
@@ -2119,10 +2544,7 @@ class OrionAPI(BaseAPI):
|
|
|
2119
2544
|
"""
|
|
2120
2545
|
if not isinstance(batch_id, int) or batch_id < 1:
|
|
2121
2546
|
raise ValueError("batch_id must be a positive integer")
|
|
2122
|
-
|
|
2123
|
-
raise ValueError("timeout must be a positive number")
|
|
2124
|
-
if not isinstance(poll_interval, (int, float)) or poll_interval <= 0:
|
|
2125
|
-
raise ValueError("poll_interval must be a positive number")
|
|
2547
|
+
_require_poll_args(timeout, poll_interval)
|
|
2126
2548
|
|
|
2127
2549
|
deadline = time.monotonic() + timeout
|
|
2128
2550
|
last_reported = None
|
|
@@ -9265,27 +9687,39 @@ class EclipseV2(EclipseBase):
|
|
|
9265
9687
|
"""Delete account set-aside cash (mutating).
|
|
9266
9688
|
|
|
9267
9689
|
Args:
|
|
9268
|
-
payload:
|
|
9690
|
+
payload: ``{"setAsideIds": [...], "skipAnalytics": bool}``
|
|
9691
|
+
(skipAnalytics optional)
|
|
9692
|
+
|
|
9693
|
+
Returns:
|
|
9694
|
+
dict | None: Parsed response, or None on an empty body. The
|
|
9695
|
+
account variant was live-verified (2026-09) to answer with an
|
|
9696
|
+
empty body.
|
|
9269
9697
|
"""
|
|
9270
9698
|
res = self.api_request(
|
|
9271
9699
|
f"{self.base_url_v2}/SetAsideCash/DeleteAccountSetAsideCash",
|
|
9272
9700
|
requests.post,
|
|
9273
9701
|
json=payload,
|
|
9274
9702
|
)
|
|
9275
|
-
return res
|
|
9703
|
+
return _json_or_none(res)
|
|
9276
9704
|
|
|
9277
9705
|
def delete_portfolio_set_aside_cash(self, payload):
|
|
9278
9706
|
"""Delete portfolio set-aside cash (mutating).
|
|
9279
9707
|
|
|
9280
9708
|
Args:
|
|
9281
|
-
payload:
|
|
9709
|
+
payload: ``{"setAsideIds": [...], "skipAnalytics": bool}``
|
|
9710
|
+
(skipAnalytics optional)
|
|
9711
|
+
|
|
9712
|
+
Returns:
|
|
9713
|
+
dict | None: Parsed response, or None on an empty body. The
|
|
9714
|
+
account variant was live-verified (2026-09) to answer with an
|
|
9715
|
+
empty body.
|
|
9282
9716
|
"""
|
|
9283
9717
|
res = self.api_request(
|
|
9284
9718
|
f"{self.base_url_v2}/SetAsideCash/DeletePortfolioSetAsideCash",
|
|
9285
9719
|
requests.post,
|
|
9286
9720
|
json=payload,
|
|
9287
9721
|
)
|
|
9288
|
-
return res
|
|
9722
|
+
return _json_or_none(res)
|
|
9289
9723
|
|
|
9290
9724
|
# =========================================================================
|
|
9291
9725
|
# Data-management CRUD / actions (v2). Account, portfolio, and model data
|
|
@@ -9358,10 +9792,16 @@ class EclipseV2(EclipseBase):
|
|
|
9358
9792
|
return res.json()
|
|
9359
9793
|
|
|
9360
9794
|
def expire_account_set_asides(self, payload):
|
|
9361
|
-
"""Expire account set-asides (mutating).
|
|
9795
|
+
"""Expire account set-asides (mutating). Raw form of expire_set_asides().
|
|
9362
9796
|
|
|
9363
9797
|
Args:
|
|
9364
|
-
payload:
|
|
9798
|
+
payload: List of ``{"setAsideId": int, "setAsideTransactions":
|
|
9799
|
+
[{"transactionId": int, "transactionExternalId": int}]}``
|
|
9800
|
+
|
|
9801
|
+
Returns:
|
|
9802
|
+
list: One result per set-aside (setAsideId, accountId,
|
|
9803
|
+
systemExpiredOn, errorMessage, ...). Failures come back as 200
|
|
9804
|
+
with a non-empty errorMessage.
|
|
9365
9805
|
"""
|
|
9366
9806
|
res = self.api_request(
|
|
9367
9807
|
f"{self.base_url_v2}/Account/Accounts/expireAccountSetAsides",
|
|
@@ -9370,6 +9810,41 @@ class EclipseV2(EclipseBase):
|
|
|
9370
9810
|
)
|
|
9371
9811
|
return res.json()
|
|
9372
9812
|
|
|
9813
|
+
def expire_set_asides(self, set_aside_ids, raise_on_error=True):
|
|
9814
|
+
"""Expire account set-asides now, keeping them as history.
|
|
9815
|
+
|
|
9816
|
+
Unlike delete_account_set_aside_cash(), an expired set-aside stays on
|
|
9817
|
+
the account: get_set_asides() still returns it with ``isActive``
|
|
9818
|
+
False and ``expiredOn`` set, and it drops out of
|
|
9819
|
+
``get_set_asides(active_only=True)``. Live-verified 2026-09.
|
|
9820
|
+
|
|
9821
|
+
Expiring an already-expired set-aside succeeds again and overwrites
|
|
9822
|
+
its ``expiredOn`` with the new time, so pass only active ids if the
|
|
9823
|
+
original expiry time matters.
|
|
9824
|
+
|
|
9825
|
+
Args:
|
|
9826
|
+
set_aside_ids: List of set-aside ids (``id`` from get_set_asides)
|
|
9827
|
+
raise_on_error: If True (default), raise OrionAPIError when any
|
|
9828
|
+
result carries an errorMessage (e.g. "No matching set aside
|
|
9829
|
+
cash found for SetAsideId: ..."). Eclipse reports those with
|
|
9830
|
+
HTTP 200, so without this they pass silently.
|
|
9831
|
+
|
|
9832
|
+
Returns:
|
|
9833
|
+
list: One result per set-aside (setAsideId, accountId,
|
|
9834
|
+
systemExpiredOn, errorMessage, ...)
|
|
9835
|
+
"""
|
|
9836
|
+
_require_positive_int_list("set_aside_ids", set_aside_ids)
|
|
9837
|
+
|
|
9838
|
+
results = self.expire_account_set_asides(
|
|
9839
|
+
[{"setAsideId": i, "setAsideTransactions": []} for i in set_aside_ids]
|
|
9840
|
+
)
|
|
9841
|
+
if raise_on_error:
|
|
9842
|
+
failed = [r for r in results or [] if r.get("errorMessage")]
|
|
9843
|
+
if failed:
|
|
9844
|
+
details = "; ".join(f"{r.get('setAsideId')}: {r['errorMessage']}" for r in failed)
|
|
9845
|
+
raise OrionAPIError(f"Failed to expire set-aside(s): {details}")
|
|
9846
|
+
return results
|
|
9847
|
+
|
|
9373
9848
|
def set_account_tags(self, payload):
|
|
9374
9849
|
"""Set tags on accounts (mutating).
|
|
9375
9850
|
|
|
File without changes
|