Skip to content

Commit

Permalink
feat: New stream trial_balances (#20)
Browse files Browse the repository at this point in the history
This makes the most basic date-range request which is probably a good
start, but there's more options:

<details><summary>Details</summary>
<p>
<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Required</th>
      <th>Type</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>reportingperiodname</td>
      <td>Optional</td>
      <td>string</td>
<td>Reporting period name. Required if not using <code
class="language-plaintext highlighter-rouge">startdate</code> and <code
class="language-plaintext highlighter-rouge">enddate</code>.</td>
    </tr>
    <tr>
      <td>startdate</td>
      <td>Optional</td>
      <td><a href="#get_trialbalance.date">object</a></td>
<td>Opening balance date. Required if not using <code
class="language-plaintext
highlighter-rouge">reportingperiodname</code>.</td>
    </tr>
    <tr>
      <td>enddate</td>
      <td>Optional</td>
      <td><a href="#get_trialbalance.date">object</a></td>
<td>Closing balance date. Required if not using <code
class="language-plaintext
highlighter-rouge">reportingperiodname</code>.</td>
    </tr>
    <tr>
      <td>debitcreditbalance</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Set to <code class="language-plaintext
highlighter-rouge">true</code> to show starting and ending balance
debits and credits. (Default: <code class="language-plaintext
highlighter-rouge">false</code>)</td>
    </tr>
    <tr>
      <td>showzerobalances</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Show zero balance accounts. Use <code class="language-plaintext
highlighter-rouge">true</code> or <code class="language-plaintext
highlighter-rouge">false</code>. (Default: <code
class="language-plaintext highlighter-rouge">false</code>)</td>
    </tr>
    <tr>
      <td>showdeptdetail</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Expand department detail. Use <code class="language-plaintext
highlighter-rouge">true</code> or <code class="language-plaintext
highlighter-rouge">false</code>. (Default: <code
class="language-plaintext highlighter-rouge">false</code>)</td>
    </tr>
    <tr>
      <td>showlocdetail</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Expand location detail. Use <code class="language-plaintext
highlighter-rouge">true</code> or <code class="language-plaintext
highlighter-rouge">false</code>. (Default: <code
class="language-plaintext highlighter-rouge">false</code>)</td>
    </tr>
    <tr>
      <td>reportingbook</td>
      <td>Optional</td>
      <td>string</td>
<td>Reporting book ID. Use <code class="language-plaintext
highlighter-rouge">ACCRUAL</code> or <code class="language-plaintext
highlighter-rouge">CASH</code> depending on the company configuration.
If Global Consolidations is enabled, you can provide a consolidation
book ID instead. (Default: Configured reporting book ID)</td>
    </tr>
    <tr>
      <td>adjbooks</td>
      <td>Optional</td>
<td>array of <code class="language-plaintext
highlighter-rouge">adjbook</code></td>
<td>Adjustment book IDs for journals that are enabled in the GL
configuration. Use <code class="language-plaintext
highlighter-rouge">GAAP</code>, <code class="language-plaintext
highlighter-rouge">TAX</code>, and/or the IDs of user defined books. Do
not append text to the IDs. If you need help, look at the Trial Balance
page in the Sage Intacct UI to see what is enabled.</td>
    </tr>
    <tr>
      <td>includereportingbook</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Combine reporting book with other adjustment books. Use <code
class="language-plaintext highlighter-rouge">true</code> to include the
reporting book entries with entries from the specified adjustment books,
or <code class="language-plaintext highlighter-rouge">false</code> to
return only entries for the specified adjustment books.</td>
    </tr>
    <tr>
      <td>statistical</td>
      <td>Optional</td>
      <td>string</td>
<td>Statistical accounts. Use either <code class="language-plaintext
highlighter-rouge">include</code>, <code class="language-plaintext
highlighter-rouge">exclude</code>, or <code class="language-plaintext
highlighter-rouge">only</code>. (Default: <code
class="language-plaintext highlighter-rouge">include</code>)</td>
    </tr>
    <tr>
      <td>departmentid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Department ID.</td>
    </tr>
    <tr>
      <td>dept_subs</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Include department subs. (Default: <code class="language-plaintext
highlighter-rouge">true</code>)</td>
    </tr>
    <tr>
      <td>locationid</td>
      <td>Optional</td>
      <td>string</td>
<td>Location ID. This field is required in a multi-base currency
company.</td>
    </tr>
    <tr>
      <td>loc_subs</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Include location subs. (Default: <code class="language-plaintext
highlighter-rouge">true</code>)</td>
    </tr>
    <tr>
      <td>projectid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Project ID or project group ID.</td>
    </tr>
    <tr>
      <td>projectid_subs</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Include project subs. (Default: <code class="language-plaintext
highlighter-rouge">true</code>)</td>
    </tr>
    <tr>
      <td>projecttypeid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Project type. Do not use if Project ID is set.</td>
    </tr>
    <tr>
      <td>taskid</td>
      <td>Optional</td>
      <td>string</td>
<td>Task ID. Only available when the parent <code
class="language-plaintext highlighter-rouge">projectid</code> is also
specified.</td>
    </tr>
    <tr>
      <td>taskid_subs</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Include task sub dimensions. (Default: <code
class="language-plaintext highlighter-rouge">true</code>)</td>
    </tr>
    <tr>
      <td>customerid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Customer ID or customer group ID.</td>
    </tr>
    <tr>
      <td>customerid_subs</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Include customer subs. (Default: <code class="language-plaintext
highlighter-rouge">true</code>)</td>
    </tr>
    <tr>
      <td>customertypeid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Customer type. Do not use if Customer ID is set.</td>
    </tr>
    <tr>
      <td>vendorid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Vendor ID or vendor group ID.</td>
    </tr>
    <tr>
      <td>vendorid_subs</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Include vendor subs. (Default: <code class="language-plaintext
highlighter-rouge">true</code>)</td>
    </tr>
    <tr>
      <td>vendortypeid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Vendor type. Do not use if Vendor ID is set.</td>
    </tr>
    <tr>
      <td>employeeid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Employee ID or employee group ID.</td>
    </tr>
    <tr>
      <td>employeeid_subs</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Include employee subs. (Default: <code class="language-plaintext
highlighter-rouge">true</code>)</td>
    </tr>
    <tr>
      <td>employeetypeid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Employee type. Do not use if Employee ID is set.</td>
    </tr>
    <tr>
      <td>itemid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Item ID or item group ID.</td>
    </tr>
    <tr>
      <td>productlineid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Product line. Do not use if Item ID is set.</td>
    </tr>
    <tr>
      <td>classid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Class ID or class group ID.</td>
    </tr>
    <tr>
      <td>classid_subs</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Include class subs. (Default: <code class="language-plaintext
highlighter-rouge">true</code>)</td>
    </tr>
    <tr>
      <td>contractid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Contract ID or contract group ID.</td>
    </tr>
    <tr>
      <td>contractid_subs</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Include contract subs. (Default: <code class="language-plaintext
highlighter-rouge">true</code>)</td>
    </tr>
    <tr>
      <td>warehouseid</td>
      <td>Optional</td>
      <td>string</td>
      <td>Warehouse ID or warehouse group ID.</td>
    </tr>
    <tr>
      <td>warehouseid_subs</td>
      <td>Optional</td>
      <td>boolean</td>
<td>Include warehouse subs. (Default: <code class="language-plaintext
highlighter-rouge">true</code>)</td>
    </tr>
    <tr>
      <td>userDefinedDimensions</td>
      <td>Optional</td>
<td>array of <code class="language-plaintext
highlighter-rouge">userDefinedDimension</code></td>
      <td>User defined dimension filters.</td>
    </tr>
  </tbody>
</table>

</p>
</details> 

<details><summary>Test results</summary>
<p>

```
tests/test_core.py::TestTapIntacct::test_tap_cli_prints <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [  6%]
tests/test_core.py::TestTapIntacct::test_tap_discovery <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 12%]
tests/test_core.py::TestTapIntacct::test_tap_stream_connections <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 18%]
tests/test_core.py::TestTapIntacct::test_tap_valid_final_state <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 25%]
tests/test_core.py::TestTapIntacct::test_tap_stream_transformed_catalog_schema_matches_record[trial_balances] <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 31%]
tests/test_core.py::TestTapIntacct::test_tap_stream_record_matches_stream_schema[trial_balances] <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 37%]
tests/test_core.py::TestTapIntacct::test_tap_stream_record_schema_matches_transformed_catalog[trial_balances] <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 43%]
tests/test_core.py::TestTapIntacct::test_tap_stream_returns_record[trial_balances] <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 50%]
tests/test_core.py::TestTapIntacct::test_tap_stream_schema_is_valid[trial_balances] <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 56%]
tests/test_core.py::TestTapIntacct::test_tap_stream_primary_keys[trial_balances] <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 62%]
tests/test_core.py::TestTapIntacct::test_tap_stream_attribute_is_numeric[trial_balances.startbalance] <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 68%]
tests/test_core.py::TestTapIntacct::test_tap_stream_attribute_is_numeric[trial_balances.debits] <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 75%]
tests/test_core.py::TestTapIntacct::test_tap_stream_attribute_is_numeric[trial_balances.credits] <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 81%]
tests/test_core.py::TestTapIntacct::test_tap_stream_attribute_is_numeric[trial_balances.adjdebits] <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 87%]
tests/test_core.py::TestTapIntacct::test_tap_stream_attribute_is_numeric[trial_balances.adjcredits] <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [ 93%]
tests/test_core.py::TestTapIntacct::test_tap_stream_attribute_is_numeric[trial_balances.endbalance] <- .venv/lib/python3.13/site-packages/singer_sdk/testing/templates.py PASSED [100%]
```

</p>
</details> 

Closes #13
  • Loading branch information
edgarrmondragon authored Dec 17, 2024
1 parent bd9f721 commit 809b47c
Show file tree
Hide file tree
Showing 6 changed files with 353 additions and 210 deletions.
323 changes: 185 additions & 138 deletions poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ packages = [

[tool.poetry.dependencies]
python = ">=3.9"
backports-datetime-fromisoformat = {version = "==2.0.2", python = "<3.11"}
singer-sdk = { version="~=0.42.1", extras = [] }
fs-s3fs = { version = "~=1.1.1", optional = true }
xmltodict = "~=0.14.2"
Expand Down
2 changes: 1 addition & 1 deletion tap_intacct/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
}

# List of available objects with their internal object-reference/endpoint name.
INTACCT_OBJECTS = {
INTACCT_OBJECTS: dict[str, str] = {
"accounts_payable_bills": "APBILL",
"accounts_payable_payments": "APPYMT",
"accounts_payable_vendors": "VENDOR",
Expand Down
223 changes: 153 additions & 70 deletions tap_intacct/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

from __future__ import annotations

import abc
import http
import json
import logging
import re
import sys
import typing as t
import uuid
from datetime import datetime, timezone
Expand Down Expand Up @@ -38,6 +40,11 @@
WrongParamsError,
)

if sys.version_info < (3, 11):
from backports.datetime_fromisoformat import MonkeyPatch

MonkeyPatch.patch_fromisoformat()

if t.TYPE_CHECKING:
from singer_sdk.helpers.types import Context

Expand Down Expand Up @@ -74,26 +81,17 @@ def has_more(self, response: requests.Response) -> bool:
return int(remaining) > 0


class IntacctStream(RESTStream):
"""Intacct stream class."""
class BaseIntacctStream(RESTStream[int], metaclass=abc.ABCMeta):
"""Base Intacct stream class."""

# Update this value if necessary or override `parse_response`.
rest_method = "POST"
path = None

def __init__(
self,
*args: t.Any,
intacct_obj_name: str | None = None,
replication_key: str | None = None,
**kwargs: t.Any,
) -> None:
def __init__(self, *args: t.Any, intacct_obj_name: str | None = None, **kwargs: t.Any) -> None:
"""Initialize stream."""
super().__init__(*args, **kwargs)
self.primary_keys = KEY_PROPERTIES[self.name]
self.intacct_obj_name = intacct_obj_name
self.replication_key = replication_key
self.session_id = self._get_session_id()
self.intacct_obj_name = intacct_obj_name
self.datetime_fields = [
i for i, t in self.schema["properties"].items() if t.get("format", "") == "date-time"
]
Expand Down Expand Up @@ -198,17 +196,6 @@ def get_new_paginator(self) -> BaseAPIPaginator:
logger_name=f"{self.tap_name}.{self.name}",
)

def _format_date_for_intacct(self, datetime: datetime) -> str:
"""Intacct expects datetimes in a 'MM/DD/YY HH:MM:SS' string format.
Args:
datetime: The datetime to be converted.
Returns:
'MM/DD/YY HH:MM:SS' formatted string.
"""
return datetime.strftime("%m/%d/%Y %H:%M:%S")

def prepare_request(
self,
context: Context | None,
Expand Down Expand Up @@ -244,17 +231,27 @@ def prepare_request(
data=request_data,
)

def _get_query_filter(
def post_process(
self,
rep_key: str,
context: Context | None,
) -> dict:
return {
"greaterthanorequalto": {
"field": rep_key,
"value": self._format_date_for_intacct(self.get_starting_timestamp(context)),
}
}
row: dict,
context: Context | None = None, # noqa: ARG002
) -> dict | None:
"""As needed, append or transform raw data to match expected structure.
Args:
row: An individual record from the stream.
context: The stream context.
Returns:
The updated record dictionary, or ``None`` to skip the record.
"""
for field in self.datetime_fields:
if row[field] is not None:
row[field] = self._parse_to_datetime(row[field])
for field in self.numeric_fields:
if row[field] is not None:
row[field] = float(row[field])
return row

def prepare_request_payload(
self,
Expand All @@ -275,26 +272,7 @@ def prepare_request_payload(
if self.name == "audit_history":
raise Exception("TODO handle audit streams") # noqa: EM101, TRY002, TRY003

rep_key = REP_KEYS.get(self.name, GET_BY_DATE_FIELD)
orderby = {
"order": {
"field": rep_key,
"ascending": {},
}
}
query_filter = self._get_query_filter(rep_key, context)
data = {
"query": {
"object": self.intacct_obj_name,
"select": {"field": list(self.schema["properties"])},
"options": {"showprivate": "true"},
"filter": query_filter,
"pagesize": PAGE_SIZE,
"offset": next_page_token,
"orderby": orderby,
}
}
key = next(iter(data))
data = self.get_request_data(context, next_page_token)
timestamp = datetime.now(timezone.utc)
dict_body = {
"request": {
Expand All @@ -308,7 +286,7 @@ def prepare_request_payload(
},
"operation": {
"authentication": {"sessionid": self.session_id},
"content": {"function": {"@controlid": str(uuid.uuid4()), key: data[key]}},
"content": {"function": {"@controlid": str(uuid.uuid4()), **data}},
},
}
}
Expand Down Expand Up @@ -473,27 +451,77 @@ def _parse_to_datetime(self, date_str: str) -> datetime:
msg = f"Invalid date format: {date_str}"
raise ValueError(msg) from err

def post_process(
@abc.abstractmethod
def get_request_data(
self,
row: dict,
context: Context | None = None, # noqa: ARG002
) -> dict | None:
"""As needed, append or transform raw data to match expected structure.
context: Context | None,
next_page_token: int | None,
) -> dict:
"""Generate request data for a general Intacct stream."""


class IntacctStream(BaseIntacctStream):
"""Intacct stream class."""

def __init__(
self,
*args: t.Any,
replication_key: str | None = None,
**kwargs: t.Any,
) -> None:
"""Initialize stream."""
super().__init__(*args, **kwargs)
self.primary_keys = KEY_PROPERTIES[self.name]
self.replication_key = replication_key

def _format_date_for_intacct(self, datetime: datetime) -> str:
"""Intacct expects datetimes in a 'MM/DD/YY HH:MM:SS' string format.
Args:
row: An individual record from the stream.
context: The stream context.
datetime: The datetime to be converted.
Returns:
The updated record dictionary, or ``None`` to skip the record.
'MM/DD/YY HH:MM:SS' formatted string.
"""
for field in self.datetime_fields:
if row[field] is not None:
row[field] = self._parse_to_datetime(row[field])
for field in self.numeric_fields:
if row[field] is not None:
row[field] = float(row[field])
return row
return datetime.strftime("%m/%d/%Y %H:%M:%S")

def _get_query_filter(
self,
rep_key: str,
context: Context | None,
) -> dict:
return {
"greaterthanorequalto": {
"field": rep_key,
"value": self._format_date_for_intacct(self.get_starting_timestamp(context)),
}
}

def get_request_data(
self,
context: Context | None,
next_page_token: t.Any | None, # noqa: ANN401
) -> dict:
"""Generate request data for a general Intacct stream."""
rep_key = REP_KEYS.get(self.name, GET_BY_DATE_FIELD)
orderby = {
"order": {
"field": rep_key,
"ascending": {},
}
}
query_filter = self._get_query_filter(rep_key, context)
return {
"query": {
"object": self.intacct_obj_name,
"select": {"field": list(self.schema["properties"])},
"options": {"showprivate": "true"},
"filter": query_filter,
"pagesize": PAGE_SIZE,
"offset": next_page_token,
"orderby": orderby,
}
}


class GeneralLedgerDetailsStream(IntacctStream):
Expand Down Expand Up @@ -548,3 +576,58 @@ def partitions(self) -> list[dict] | None:
{"MODULEKEY": "48.PROJACCT", "name": "Project and Resource Management"},
{"MODULEKEY": "55.CONTRACT", "name": "Contracts and Revenue Management"},
]


class TrialBalancesStream(BaseIntacctStream):
"""Trial balances.
https://developer.intacct.com/api/general-ledger/trial-balances/
"""

name = "trial_balances"
primary_keys = ("glaccountno",)

schema = th.PropertiesList(
th.Property("glaccountno", th.StringType),
th.Property("startbalance", th.NumberType),
th.Property("debits", th.NumberType),
th.Property("credits", th.NumberType),
th.Property("adjdebits", th.NumberType),
th.Property("adjcredits", th.NumberType),
th.Property("endbalance", th.NumberType),
th.Property("reportingbook", th.StringType),
th.Property("currency", th.StringType),
).to_dict()

def get_request_data(
self,
context: Context | None, # noqa: ARG002
next_page_token: int | None, # noqa: ARG002
) -> dict:
"""Generate request data for trial balances."""
raw_start_date: str | None = self.config.get("start_date")
end_date = datetime.now(timezone.utc)

if not raw_start_date:
msg = f"A starting timestamp is required for '{self.name}'"
raise RuntimeError(msg)

start_date = datetime.fromisoformat(raw_start_date)

start_date_obj = {
"year": start_date.year,
"month": start_date.month,
"day": start_date.day,
}
end_date_obj = {
"year": end_date.year,
"month": end_date.month,
"day": end_date.day,
}

return {
"get_trialbalance": {
"startdate": start_date_obj,
"enddate": end_date_obj,
}
}
9 changes: 9 additions & 0 deletions tap_intacct/tap.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ def discover_streams(self) -> list[streams.IntacctStream]:
# replication_key="ACCESSTIME",
# )
# discovered_streams.append(audit_stream)

discovered_streams.append(
streams.TrialBalancesStream(
tap=self,
name="trial_balances",
intacct_obj_name="trialbalance",
)
)

return discovered_streams


Expand Down
5 changes: 4 additions & 1 deletion tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import datetime

from singer_sdk.testing import get_tap_test_class
from singer_sdk.testing import SuiteConfig, get_tap_test_class

from tap_intacct.tap import TapIntacct

Expand All @@ -15,4 +15,7 @@
TestTapIntacct = get_tap_test_class(
tap_class=TapIntacct,
config=SAMPLE_CONFIG,
suite_config=SuiteConfig(
max_records_limit=15,
),
)

0 comments on commit 809b47c

Please sign in to comment.