orionapi 2.30.0__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
|
|
@@ -4843,46 +5265,46 @@ class EclipseV1(EclipseBase):
|
|
|
4843
5265
|
def set_portfolio_tradeable(self, portfolio_id, tradeable=True, sync=True):
|
|
4844
5266
|
"""Set whether trading is allowed for a portfolio.
|
|
4845
5267
|
|
|
5268
|
+
Uses the v2 ``setPortfolioTradeBlock`` action, which accepts only
|
|
5269
|
+
``{"id", "doNotTrade"}`` and therefore cannot touch any other portfolio
|
|
5270
|
+
field. The older approach (GET the portfolio, rebuild the v1 PUT payload
|
|
5271
|
+
by hand) rejected portfolios with no primary team and risked stripping
|
|
5272
|
+
teams, trading instructions and auto-rebalance settings that the
|
|
5273
|
+
rebuilt payload did not carry. Verified live 2026-09-10 on portfolio
|
|
5274
|
+
209: the flag flips and every ``general``/``teams`` field is unchanged.
|
|
5275
|
+
|
|
4846
5276
|
Args:
|
|
4847
5277
|
portfolio_id: Portfolio ID
|
|
4848
5278
|
tradeable: True to allow trading, False to block (default True)
|
|
4849
5279
|
sync: Wait for analytics to complete (default True)
|
|
4850
5280
|
|
|
4851
5281
|
Returns:
|
|
4852
|
-
dict: Updated portfolio details
|
|
5282
|
+
dict: Updated portfolio details (same shape as :meth:`get_portfolio`)
|
|
4853
5283
|
"""
|
|
4854
5284
|
if not isinstance(portfolio_id, int) or portfolio_id < 1:
|
|
4855
5285
|
raise ValueError("portfolio_id must be a positive integer")
|
|
4856
5286
|
if not isinstance(tradeable, bool):
|
|
4857
5287
|
raise ValueError("tradeable must be a boolean")
|
|
4858
5288
|
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
general = portfolio.get("general", {})
|
|
4862
|
-
|
|
4863
|
-
# Build payload preserving existing fields
|
|
4864
|
-
payload = {
|
|
4865
|
-
"name": general.get("portfolioName"),
|
|
4866
|
-
"modelId": general.get("modelId"),
|
|
4867
|
-
"isSleevePortfolio": general.get("sleevePortfolio", False),
|
|
4868
|
-
"doNotTrade": 0 if tradeable else 1,
|
|
4869
|
-
"tags": general.get("tags", ""),
|
|
4870
|
-
"teamIds": general.get("teamIds", []),
|
|
4871
|
-
"primaryTeamId": general.get("primaryTeamId"),
|
|
4872
|
-
}
|
|
4873
|
-
|
|
4874
|
-
res = self.api_request(
|
|
4875
|
-
f"{self.base_url}/portfolio/portfolios/{portfolio_id}",
|
|
5289
|
+
self.api_request(
|
|
5290
|
+
f"{self.base_url_v2}/Portfolio/Portfolios/action/setPortfolioTradeBlock",
|
|
4876
5291
|
requests.put,
|
|
4877
|
-
json=
|
|
5292
|
+
json=[{"id": portfolio_id, "doNotTrade": not tradeable}],
|
|
4878
5293
|
)
|
|
4879
|
-
result =
|
|
5294
|
+
result = self.get_portfolio(portfolio_id)
|
|
4880
5295
|
self._maybe_wait_for_analytics(sync)
|
|
4881
5296
|
return result
|
|
4882
5297
|
|
|
4883
5298
|
def set_account_tradeable(self, account_id, trade_restriction="tradeable", sync=True):
|
|
4884
5299
|
"""Set trading restrictions for an account.
|
|
4885
5300
|
|
|
5301
|
+
Uses the v2 ``setAccountTradeBlock`` action, which accepts only
|
|
5302
|
+
``{"id", "isDoNotBuySell", "isCustodialRestriction"}`` and therefore
|
|
5303
|
+
cannot touch any other account field. The older approach rebuilt the
|
|
5304
|
+
v1 ``PUT /account/accounts/{id}`` payload from ``generalSection`` of the
|
|
5305
|
+
GET DTO, but the account DTO is flat (no ``generalSection``), so it was
|
|
5306
|
+
sending ``accountName: null`` / ``portfolioId: null`` alongside the flags.
|
|
5307
|
+
|
|
4886
5308
|
Args:
|
|
4887
5309
|
account_id: Internal Eclipse account ID
|
|
4888
5310
|
trade_restriction: One of:
|
|
@@ -4892,7 +5314,8 @@ class EclipseV1(EclipseBase):
|
|
|
4892
5314
|
sync: Wait for analytics to complete (default True)
|
|
4893
5315
|
|
|
4894
5316
|
Returns:
|
|
4895
|
-
dict: Updated account details
|
|
5317
|
+
dict: Updated account details (same shape as :meth:`get_account_details`;
|
|
5318
|
+
the flags are ``isDoNotBuySell`` and ``isCustodialRestriction``)
|
|
4896
5319
|
|
|
4897
5320
|
Note:
|
|
4898
5321
|
These are mutually exclusive options - only one can be active at a time.
|
|
@@ -4900,35 +5323,27 @@ class EclipseV1(EclipseBase):
|
|
|
4900
5323
|
if not isinstance(account_id, int) or account_id < 1:
|
|
4901
5324
|
raise ValueError("account_id must be a positive integer")
|
|
4902
5325
|
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
# Get current account details
|
|
4908
|
-
account = self.get_account_details(account_id)
|
|
4909
|
-
general = account.get("generalSection", {})
|
|
4910
|
-
|
|
4911
|
-
# Build payload
|
|
4912
|
-
payload = {
|
|
4913
|
-
"accountName": general.get("accountName"),
|
|
4914
|
-
"portfolioId": general.get("portfolioId"),
|
|
5326
|
+
flags = {
|
|
5327
|
+
"tradeable": (False, False),
|
|
5328
|
+
"block_advisor": (True, False),
|
|
5329
|
+
"block_custodian": (False, True),
|
|
4915
5330
|
}
|
|
5331
|
+
if trade_restriction not in flags:
|
|
5332
|
+
raise ValueError(f"trade_restriction must be one of {list(flags)}")
|
|
5333
|
+
do_not_buy_sell, custodial_restriction = flags[trade_restriction]
|
|
4916
5334
|
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
res = self.api_request(
|
|
4929
|
-
f"{self.base_url}/account/accounts/{account_id}", requests.put, json=payload
|
|
5335
|
+
self.api_request(
|
|
5336
|
+
f"{self.base_url_v2}/Account/Accounts/action/setAccountTradeBlock",
|
|
5337
|
+
requests.put,
|
|
5338
|
+
json=[
|
|
5339
|
+
{
|
|
5340
|
+
"id": account_id,
|
|
5341
|
+
"isDoNotBuySell": do_not_buy_sell,
|
|
5342
|
+
"isCustodialRestriction": custodial_restriction,
|
|
5343
|
+
}
|
|
5344
|
+
],
|
|
4930
5345
|
)
|
|
4931
|
-
result =
|
|
5346
|
+
result = self.get_account_details(account_id)
|
|
4932
5347
|
self._maybe_wait_for_analytics(sync)
|
|
4933
5348
|
return result
|
|
4934
5349
|
|
|
@@ -9272,27 +9687,39 @@ class EclipseV2(EclipseBase):
|
|
|
9272
9687
|
"""Delete account set-aside cash (mutating).
|
|
9273
9688
|
|
|
9274
9689
|
Args:
|
|
9275
|
-
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.
|
|
9276
9697
|
"""
|
|
9277
9698
|
res = self.api_request(
|
|
9278
9699
|
f"{self.base_url_v2}/SetAsideCash/DeleteAccountSetAsideCash",
|
|
9279
9700
|
requests.post,
|
|
9280
9701
|
json=payload,
|
|
9281
9702
|
)
|
|
9282
|
-
return res
|
|
9703
|
+
return _json_or_none(res)
|
|
9283
9704
|
|
|
9284
9705
|
def delete_portfolio_set_aside_cash(self, payload):
|
|
9285
9706
|
"""Delete portfolio set-aside cash (mutating).
|
|
9286
9707
|
|
|
9287
9708
|
Args:
|
|
9288
|
-
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.
|
|
9289
9716
|
"""
|
|
9290
9717
|
res = self.api_request(
|
|
9291
9718
|
f"{self.base_url_v2}/SetAsideCash/DeletePortfolioSetAsideCash",
|
|
9292
9719
|
requests.post,
|
|
9293
9720
|
json=payload,
|
|
9294
9721
|
)
|
|
9295
|
-
return res
|
|
9722
|
+
return _json_or_none(res)
|
|
9296
9723
|
|
|
9297
9724
|
# =========================================================================
|
|
9298
9725
|
# Data-management CRUD / actions (v2). Account, portfolio, and model data
|
|
@@ -9365,10 +9792,16 @@ class EclipseV2(EclipseBase):
|
|
|
9365
9792
|
return res.json()
|
|
9366
9793
|
|
|
9367
9794
|
def expire_account_set_asides(self, payload):
|
|
9368
|
-
"""Expire account set-asides (mutating).
|
|
9795
|
+
"""Expire account set-asides (mutating). Raw form of expire_set_asides().
|
|
9369
9796
|
|
|
9370
9797
|
Args:
|
|
9371
|
-
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.
|
|
9372
9805
|
"""
|
|
9373
9806
|
res = self.api_request(
|
|
9374
9807
|
f"{self.base_url_v2}/Account/Accounts/expireAccountSetAsides",
|
|
@@ -9377,6 +9810,41 @@ class EclipseV2(EclipseBase):
|
|
|
9377
9810
|
)
|
|
9378
9811
|
return res.json()
|
|
9379
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
|
+
|
|
9380
9848
|
def set_account_tags(self, payload):
|
|
9381
9849
|
"""Set tags on accounts (mutating).
|
|
9382
9850
|
|
|
File without changes
|