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.30.0
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.30.0"
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
- """Generate bills for a billing instance.
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: Generation result
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.json()
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
- res = self.api_request(
1522
- f"{self.base_url}/Billing/Instances/{instance_id}/Action/Invalidate",
1523
- requests.post,
1524
- )
1525
- return res.json()
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
- res = self.api_request(url, requests.post, json={"ids": [instance_id]})
1547
- return res.json()
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 (or updates) a set-aside in Eclipse for the account's
1782
- balance due, so the fee cash is reserved from trading.
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
- res = self.api_request(url, requests.put, json={"ids": bill_ids})
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
- if not isinstance(timeout, (int, float)) or timeout <= 0:
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
- # Get current portfolio to preserve other fields
4860
- portfolio = self.get_portfolio(portfolio_id)
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=payload,
5292
+ json=[{"id": portfolio_id, "doNotTrade": not tradeable}],
4878
5293
  )
4879
- result = res.json()
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
- valid_restrictions = ["tradeable", "block_advisor", "block_custodian"]
4904
- if trade_restriction not in valid_restrictions:
4905
- raise ValueError(f"trade_restriction must be one of {valid_restrictions}")
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
- # Set flags based on restriction type
4918
- if trade_restriction == "tradeable":
4919
- payload["doNotTrade"] = 0
4920
- payload["doNotTradeCustodian"] = 0
4921
- elif trade_restriction == "block_advisor":
4922
- payload["doNotTrade"] = 1
4923
- payload["doNotTradeCustodian"] = 0
4924
- elif trade_restriction == "block_custodian":
4925
- payload["doNotTrade"] = 0
4926
- payload["doNotTradeCustodian"] = 1
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 = res.json()
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: DTO identifying the account set-asides to delete (request body)
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.json()
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: DTO identifying the portfolio set-asides to delete (request body)
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.json()
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: DTO identifying the set-asides to expire (request body)
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
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "orionapi"
3
- version = "2.30.0"
3
+ version = "2.31.0"
4
4
  description = "A python interface for the Orion Advisors platform web API"
5
5
  authors = ["spencerogden-dsam <67068943+spencerogden-dsam@users.noreply.github.com>"]
6
6
 
File without changes