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.30.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.30.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
- """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
@@ -9265,27 +9687,39 @@ class EclipseV2(EclipseBase):
9265
9687
  """Delete account set-aside cash (mutating).
9266
9688
 
9267
9689
  Args:
9268
- 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.
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.json()
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: 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.
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.json()
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: 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.
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
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "orionapi"
3
- version = "2.30.2"
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