├── setup.cfg ├── MANIFEST.in ├── tap_xero ├── schemas │ ├── attachments.json │ ├── currencies.json │ ├── validation_errors.json │ ├── contact_groups.json │ ├── phones.json │ ├── branding_themes.json │ ├── allocations.json │ ├── employees.json │ ├── payment_terms.json │ ├── users.json │ ├── invoice_address.json │ ├── addresses.json │ ├── tax_breakdown_component.json │ ├── linked_transactions.json │ ├── bank_transfers.json │ ├── expense_claims.json │ ├── accounts.json │ ├── tracking_categories.json │ ├── receipts.json │ ├── line_items.json │ ├── tax_rates.json │ ├── items.json │ ├── journals.json │ ├── manual_journals.json │ ├── bank_transactions.json │ ├── payments.json │ ├── repeating_invoices.json │ ├── overpayments.json │ ├── prepayments.json │ ├── organisations.json │ ├── quotes.json │ ├── credit_notes.json │ ├── purchase_orders.json │ ├── nested_invoice.json │ ├── invoices.json │ └── contacts.json ├── context.py ├── transform.py ├── __init__.py ├── streams.py └── client.py ├── .github └── pull_request_template.md ├── setup.py ├── .gitignore ├── tests ├── unittests │ ├── test_datetime_parsing.py │ ├── test_archived_contacts_support.py │ └── test_exception_handling.py ├── test_archived_contacts_support.py ├── test_xero_future_dates_no_data.py ├── test_xero_bookmarks.py └── base.py ├── .circleci └── config.yml ├── README.md └── CHANGELOG.md /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include tap_xero/schemas/*.json 3 | -------------------------------------------------------------------------------- /tap_xero/schemas/attachments.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "array" 5 | ], 6 | "items": { 7 | "type": [ 8 | "null", 9 | "object" 10 | ], 11 | "properties": { 12 | }, 13 | "additionalProperties": false 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tap_xero/schemas/currencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Description": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "Code": { 14 | "type": [ 15 | "string" 16 | ] 17 | } 18 | }, 19 | "additionalProperties": false 20 | } 21 | -------------------------------------------------------------------------------- /tap_xero/schemas/validation_errors.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "array" 5 | ], 6 | "items": { 7 | "type": [ 8 | "null", 9 | "object" 10 | ], 11 | "properties": { 12 | "Message": { 13 | "type": [ 14 | "null", 15 | "string" 16 | ] 17 | } 18 | }, 19 | "additionalProperties": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description of change 2 | (write a short description or paste a link to JIRA) 3 | 4 | # Manual QA steps 5 | - 6 | 7 | # Risks 8 | - 9 | 10 | # Rollback steps 11 | - revert this branch 12 | 13 | #### AI generated code 14 | https://internal.qlik.dev/general/ways-of-working/code-reviews/#guidelines-for-ai-generated-code 15 | - [ ] this PR has been written with the help of GitHub Copilot or another generative AI tool 16 | -------------------------------------------------------------------------------- /tap_xero/schemas/contact_groups.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Status": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "Name": { 14 | "type": [ 15 | "null", 16 | "string" 17 | ] 18 | }, 19 | "ContactGroupID": { 20 | "type": [ 21 | "string" 22 | ] 23 | }, 24 | "HasValidationErrors": { 25 | "type": [ 26 | "null", 27 | "boolean" 28 | ] 29 | } 30 | }, 31 | "additionalProperties": false 32 | } 33 | -------------------------------------------------------------------------------- /tap_xero/schemas/phones.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "PhoneNumber": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "PhoneAreaCode": { 14 | "type": [ 15 | "null", 16 | "string" 17 | ] 18 | }, 19 | "PhoneCountryCode": { 20 | "type": [ 21 | "null", 22 | "string" 23 | ] 24 | }, 25 | "PhoneType": { 26 | "type": [ 27 | "null", 28 | "string" 29 | ] 30 | } 31 | }, 32 | "additionalProperties": false 33 | } 34 | -------------------------------------------------------------------------------- /tap_xero/schemas/branding_themes.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "CreatedDateUTC": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ], 12 | "format": "date-time" 13 | }, 14 | "SortOrder": { 15 | "type": [ 16 | "null", 17 | "integer" 18 | ] 19 | }, 20 | "Name": { 21 | "type": [ 22 | "null", 23 | "string" 24 | ] 25 | }, 26 | "BrandingThemeID": { 27 | "type": [ 28 | "string" 29 | ] 30 | } 31 | }, 32 | "additionalProperties": false 33 | } 34 | -------------------------------------------------------------------------------- /tap_xero/schemas/allocations.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Date": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ], 12 | "format": "date-time" 13 | }, 14 | "Amount": { 15 | "type": [ 16 | "null", 17 | "number" 18 | ], 19 | "minimum": -1e+33, 20 | "maximum": 1e+33, 21 | "multipleOf": 1e-05, 22 | "exclusiveMinimum": true, 23 | "exclusiveMaximum": true 24 | }, 25 | "Invoice": { 26 | "$ref": "nested_invoice" 27 | } 28 | }, 29 | "additionalProperties": false, 30 | "tap_schema_dependencies": [ 31 | "nested_invoice" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /tap_xero/schemas/employees.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Status": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "FirstName": { 14 | "type": [ 15 | "null", 16 | "string" 17 | ] 18 | }, 19 | "LastName": { 20 | "type": [ 21 | "null", 22 | "string" 23 | ] 24 | }, 25 | "ExternalLink": { 26 | "type": [ 27 | "null", 28 | "string" 29 | ] 30 | }, 31 | "UpdatedDateUTC": { 32 | "type": [ 33 | "null", 34 | "string" 35 | ], 36 | "format": "date-time" 37 | }, 38 | "EmployeeID": { 39 | "type": [ 40 | "string" 41 | ] 42 | } 43 | }, 44 | "additionalProperties": false 45 | } 46 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup, find_packages 3 | 4 | setup(name="tap-xero", 5 | version="2.3.2", 6 | description="Singer.io tap for extracting data from the Xero API", 7 | author="Stitch", 8 | url="http://singer.io", 9 | classifiers=["Programming Language :: Python :: 3 :: Only"], 10 | py_modules=["tap_xero"], 11 | install_requires=[ 12 | "singer-python==5.13.2", 13 | "requests==2.32.4", 14 | ], 15 | extras_require={ 16 | 'dev': [ 17 | 'ipdb', 18 | 'pylint', 19 | 'nose' 20 | ] 21 | }, 22 | entry_points=""" 23 | [console_scripts] 24 | tap-xero=tap_xero:main 25 | """, 26 | packages=["tap_xero"], 27 | package_data = { 28 | "schemas": ["tap_xero/schemas/*.json"] 29 | }, 30 | include_package_data=True, 31 | ) 32 | -------------------------------------------------------------------------------- /tap_xero/schemas/payment_terms.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Sales": { 8 | "type": [ 9 | "null", 10 | "object" 11 | ], 12 | "properties": { 13 | "Day": { 14 | "type": [ 15 | "null", 16 | "integer" 17 | ] 18 | }, 19 | "Type": { 20 | "type": [ 21 | "null", 22 | "string" 23 | ] 24 | } 25 | }, 26 | "additionalProperties": false 27 | }, 28 | "Bills": { 29 | "type": [ 30 | "null", 31 | "object" 32 | ], 33 | "properties": { 34 | "Day": { 35 | "type": [ 36 | "null", 37 | "integer" 38 | ] 39 | }, 40 | "Type": { 41 | "type": [ 42 | "null", 43 | "string" 44 | ] 45 | } 46 | }, 47 | "additionalProperties": false 48 | } 49 | }, 50 | "additionalProperties": false 51 | } 52 | -------------------------------------------------------------------------------- /tap_xero/schemas/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "UserID": { 8 | "type": [ 9 | "string" 10 | ] 11 | }, 12 | "EmailAddress": { 13 | "type": [ 14 | "null", 15 | "string" 16 | ] 17 | }, 18 | "FirstName": { 19 | "type": [ 20 | "null", 21 | "string" 22 | ] 23 | }, 24 | "LastName": { 25 | "type": [ 26 | "null", 27 | "string" 28 | ] 29 | }, 30 | "UpdatedDateUTC": { 31 | "format": "date-time", 32 | "type": [ 33 | "null", 34 | "string" 35 | ] 36 | }, 37 | "IsSubscriber": { 38 | "type": [ 39 | "null", 40 | "boolean" 41 | ] 42 | }, 43 | "OrganisationRole": { 44 | "type": [ 45 | "null", 46 | "string" 47 | ] 48 | }, 49 | "ValidationErrors": { 50 | "$ref": "validation_errors" 51 | } 52 | }, 53 | "additionalProperties": false, 54 | "tap_schema_dependencies": [ 55 | "validation_errors" 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /tap_xero/schemas/invoice_address.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "InvoiceAddressType": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "AddressLine1": { 14 | "type": [ 15 | "null", 16 | "string" 17 | ] 18 | }, 19 | "AddressLine2": { 20 | "type": [ 21 | "null", 22 | "string" 23 | ] 24 | }, 25 | "AddressLine3": { 26 | "type": [ 27 | "null", 28 | "string" 29 | ] 30 | }, 31 | "AddressLine4": { 32 | "type": [ 33 | "null", 34 | "string" 35 | ] 36 | }, 37 | "City": { 38 | "type": [ 39 | "null", 40 | "string" 41 | ] 42 | }, 43 | "Region": { 44 | "type": [ 45 | "null", 46 | "string" 47 | ] 48 | }, 49 | "PostalCode": { 50 | "type": [ 51 | "null", 52 | "string" 53 | ] 54 | }, 55 | "Country": { 56 | "type": [ 57 | "null", 58 | "string" 59 | ] 60 | } 61 | }, 62 | "additionalProperties": false 63 | } 64 | -------------------------------------------------------------------------------- /tap_xero/schemas/addresses.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Region": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "AddressLine1": { 14 | "type": [ 15 | "null", 16 | "string" 17 | ] 18 | }, 19 | "AddressLine2": { 20 | "type": [ 21 | "null", 22 | "string" 23 | ] 24 | }, 25 | "AddressLine3": { 26 | "type": [ 27 | "null", 28 | "string" 29 | ] 30 | }, 31 | "AddressLine4": { 32 | "type": [ 33 | "null", 34 | "string" 35 | ] 36 | }, 37 | "AttentionTo": { 38 | "type": [ 39 | "null", 40 | "string" 41 | ] 42 | }, 43 | "City": { 44 | "type": [ 45 | "null", 46 | "string" 47 | ] 48 | }, 49 | "PostalCode": { 50 | "type": [ 51 | "null", 52 | "string" 53 | ] 54 | }, 55 | "Country": { 56 | "type": [ 57 | "null", 58 | "string" 59 | ] 60 | }, 61 | "AddressType": { 62 | "type": [ 63 | "null", 64 | "string" 65 | ] 66 | } 67 | }, 68 | "additionalProperties": false 69 | } 70 | -------------------------------------------------------------------------------- /tap_xero/schemas/tax_breakdown_component.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "TaxComponentId": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "Type": { 14 | "type": [ 15 | "null", 16 | "string" 17 | ] 18 | }, 19 | "Name": { 20 | "type": [ 21 | "null", 22 | "string" 23 | ] 24 | }, 25 | "TaxPercentage": { 26 | "type": [ 27 | "null", 28 | "number" 29 | ] 30 | }, 31 | "TaxAmount": { 32 | "type": [ 33 | "null", 34 | "number" 35 | ] 36 | }, 37 | "TaxableAmount": { 38 | "type": [ 39 | "null", 40 | "number" 41 | ] 42 | }, 43 | "NonTaxableAmount": { 44 | "type": [ 45 | "null", 46 | "number" 47 | ] 48 | }, 49 | "ExemptAmount": { 50 | "type": [ 51 | "null", 52 | "number" 53 | ] 54 | }, 55 | "StateAssignedNo": { 56 | "type": [ 57 | "null", 58 | "string" 59 | ] 60 | }, 61 | "JurisdictionRegion": { 62 | "type": [ 63 | "null", 64 | "string" 65 | ] 66 | } 67 | }, 68 | "additionalProperties": false 69 | } 70 | -------------------------------------------------------------------------------- /tap_xero/schemas/linked_transactions.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "LinkedTransactionID": { 8 | "type": [ 9 | "string" 10 | ] 11 | }, 12 | "Status": { 13 | "type": [ 14 | "null", 15 | "string" 16 | ] 17 | }, 18 | "Type": { 19 | "type": [ 20 | "null", 21 | "string" 22 | ] 23 | }, 24 | "SourceTransactionID": { 25 | "type": [ 26 | "null", 27 | "string" 28 | ] 29 | }, 30 | "SourceLineItemID": { 31 | "type": [ 32 | "null", 33 | "string" 34 | ] 35 | }, 36 | "SourceTransactionTypeCode": { 37 | "type": [ 38 | "null", 39 | "string" 40 | ] 41 | }, 42 | "ContactID": { 43 | "type": [ 44 | "null", 45 | "string" 46 | ] 47 | }, 48 | "TargetTransactionID": { 49 | "type": [ 50 | "null", 51 | "string" 52 | ] 53 | }, 54 | "TargetLineItemID": { 55 | "type": [ 56 | "null", 57 | "string" 58 | ] 59 | }, 60 | "UpdatedDateUTC": { 61 | "type": [ 62 | "null", 63 | "string" 64 | ], 65 | "format": "date-time" 66 | } 67 | }, 68 | "additionalProperties": false 69 | } 70 | -------------------------------------------------------------------------------- /tap_xero/context.py: -------------------------------------------------------------------------------- 1 | import singer 2 | from singer import bookmarks as bks_ 3 | from .client import XeroClient 4 | 5 | 6 | class Context(): 7 | def __init__(self, config, state, catalog, config_path): 8 | self.config = config 9 | self.config_path = config_path 10 | self.state = state 11 | self.catalog = catalog 12 | self.client = XeroClient(config) 13 | 14 | def refresh_credentials(self): 15 | self.client.refresh_credentials(self.config, self.config_path) 16 | 17 | def check_platform_access(self): 18 | self.client.check_platform_access(self.config, self.config_path) 19 | 20 | def get_bookmark(self, path): 21 | return bks_.get_bookmark(self.state, *path) 22 | 23 | def set_bookmark(self, path, val): 24 | bks_.write_bookmark(self.state, path[0], path[1], val) 25 | 26 | def get_offset(self, path): 27 | off = bks_.get_offset(self.state, path[0]) 28 | return (off or {}).get(path[1]) 29 | 30 | def set_offset(self, path, val): 31 | bks_.set_offset(self.state, path[0], path[1], val) 32 | 33 | def clear_offsets(self, tap_stream_id): 34 | bks_.clear_offset(self.state, tap_stream_id) 35 | 36 | def update_start_date_bookmark(self, path): 37 | val = self.get_bookmark(path) 38 | if not val: 39 | val = self.config["start_date"] 40 | self.set_bookmark(path, val) 41 | return val 42 | 43 | def write_state(self): 44 | singer.write_state(self.state) 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # Mac 92 | ._* 93 | .DS_Store 94 | 95 | # Custom stuff 96 | env.sh 97 | config.json 98 | .autoenv.zsh 99 | 100 | rsa-key 101 | tags 102 | -------------------------------------------------------------------------------- /tests/unittests/test_datetime_parsing.py: -------------------------------------------------------------------------------- 1 | from tap_xero.client import parse_date 2 | from singer.utils import strptime_to_utc 3 | import unittest 4 | import datetime 5 | 6 | class TestDatetimeParsing(unittest.TestCase): 7 | 8 | def test_normal_datetimes(self): 9 | dates = [ 10 | '2020-10-20T12:30:00Z', 11 | '2020-01-02T16:30:00Z', 12 | '2020-01-01T12:30:00+0', 13 | ] 14 | 15 | parsed_dates = [parse_date(x) for x in dates] 16 | 17 | expected_dates = [ 18 | strptime_to_utc('2020-10-20T12:30:00Z'), 19 | strptime_to_utc('2020-01-02T16:30:00Z'), 20 | strptime_to_utc('2020-01-01T12:30:00+0'), 21 | ] 22 | 23 | self.assertEquals(parsed_dates, expected_dates) 24 | 25 | def test_epoch_datetimes(self): 26 | dates = [ 27 | '/Date(1603895333000+0000)/', 28 | '/Date(814890533000+0000)/', 29 | '/Date(1130509733000+0000)/', 30 | '/Date(-1565568000000+0000)/', 31 | ] 32 | 33 | parsed_dates = [parse_date(x) for x in dates] 34 | 35 | expected_dates = [ 36 | datetime.datetime(2020, 10, 28, 14, 28, 53), 37 | datetime.datetime(1995, 10, 28, 14, 28, 53), 38 | datetime.datetime(2005, 10, 28, 14, 28, 53), 39 | datetime.datetime(1920, 5, 23, 00, 00, 00), 40 | ] 41 | 42 | self.assertEquals(parsed_dates, expected_dates) 43 | 44 | def test_not_datetimes(self): 45 | dates = [ 46 | '1023', 47 | 'abcsdf', 48 | '0020', 49 | '0023' 50 | ] 51 | 52 | parsed_dates = [parse_date(x) for x in dates] 53 | 54 | expected_dates = [None, None, None, None] 55 | 56 | self.assertEquals(parsed_dates, expected_dates) 57 | -------------------------------------------------------------------------------- /tap_xero/schemas/bank_transfers.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "FromBankAccount": { 8 | "$ref": "accounts" 9 | }, 10 | "ToBankAccount": { 11 | "$ref": "accounts" 12 | }, 13 | "Amount": { 14 | "type": [ 15 | "null", 16 | "number" 17 | ], 18 | "minimum": -1e+33, 19 | "maximum": 1e+33, 20 | "multipleOf": 1e-05, 21 | "exclusiveMinimum": true, 22 | "exclusiveMaximum": true 23 | }, 24 | "Date": { 25 | "type": [ 26 | "null", 27 | "string" 28 | ], 29 | "format": "date-time" 30 | }, 31 | "DateString": { 32 | "type": [ 33 | "null", 34 | "string" 35 | ], 36 | "format": "date-time" 37 | }, 38 | "BankTransferID": { 39 | "type": [ 40 | "string" 41 | ] 42 | }, 43 | "CurrencyRate": { 44 | "type": [ 45 | "null", 46 | "number" 47 | ], 48 | "minimum": -1e+33, 49 | "maximum": 1e+33, 50 | "multipleOf": 1e-10, 51 | "exclusiveMinimum": true, 52 | "exclusiveMaximum": true 53 | }, 54 | "FromBankTransactionID": { 55 | "type": [ 56 | "null", 57 | "string" 58 | ] 59 | }, 60 | "ToBankTransactionID": { 61 | "type": [ 62 | "null", 63 | "string" 64 | ] 65 | }, 66 | "HasAttachments": { 67 | "type": [ 68 | "null", 69 | "boolean" 70 | ] 71 | }, 72 | "CreatedDateUTC": { 73 | "type": [ 74 | "null", 75 | "string" 76 | ], 77 | "format": "date-time" 78 | }, 79 | "CreatedDateUTCString": { 80 | "type": [ 81 | "null", 82 | "string" 83 | ], 84 | "format": "date-time" 85 | } 86 | }, 87 | "tap_schema_dependencies": [ 88 | "accounts" 89 | ], 90 | "additionalProperties": false 91 | } 92 | -------------------------------------------------------------------------------- /tap_xero/schemas/expense_claims.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "User": { 8 | "$ref": "users" 9 | }, 10 | "Receipts": { 11 | "type": [ 12 | "null", 13 | "array" 14 | ], 15 | "items": { 16 | "$ref": "receipts" 17 | } 18 | }, 19 | "ExpenseClaimID": { 20 | "type": [ 21 | "string" 22 | ] 23 | }, 24 | "Payments": { 25 | "type": [ 26 | "null", 27 | "array" 28 | ], 29 | "items": { 30 | "$ref": "payments" 31 | } 32 | }, 33 | "Status": { 34 | "type": [ 35 | "null", 36 | "string" 37 | ] 38 | }, 39 | "UpdatedDateUTC": { 40 | "type": [ 41 | "null", 42 | "string" 43 | ], 44 | "format": "date-time" 45 | }, 46 | "Total": { 47 | "type": [ 48 | "null", 49 | "number" 50 | ], 51 | "minimum": -1e+33, 52 | "maximum": 1e+33, 53 | "multipleOf": 1e-05, 54 | "exclusiveMinimum": true, 55 | "exclusiveMaximum": true 56 | }, 57 | "AmountDue": { 58 | "type": [ 59 | "null", 60 | "number" 61 | ], 62 | "minimum": -1e+33, 63 | "maximum": 1e+33, 64 | "multipleOf": 1e-05, 65 | "exclusiveMinimum": true, 66 | "exclusiveMaximum": true 67 | }, 68 | "AmountPaid": { 69 | "type": [ 70 | "null", 71 | "number" 72 | ], 73 | "minimum": -1e+33, 74 | "maximum": 1e+33, 75 | "multipleOf": 1e-05, 76 | "exclusiveMinimum": true, 77 | "exclusiveMaximum": true 78 | }, 79 | "PaymentDueDate": { 80 | "type": [ 81 | "null", 82 | "string" 83 | ], 84 | "format": "date-time" 85 | }, 86 | "ReportingDate": { 87 | "type": [ 88 | "null", 89 | "string" 90 | ], 91 | "format": "date-time" 92 | } 93 | }, 94 | "tap_schema_dependencies": [ 95 | "users", 96 | "receipts", 97 | "payments" 98 | ], 99 | "additionalProperties": false 100 | } 101 | -------------------------------------------------------------------------------- /tap_xero/schemas/accounts.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Code": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "Name": { 14 | "type": [ 15 | "null", 16 | "string" 17 | ] 18 | }, 19 | "Type": { 20 | "type": [ 21 | "null", 22 | "string" 23 | ] 24 | }, 25 | "UpdatedDateUTC": { 26 | "type": [ 27 | "null", 28 | "string" 29 | ], 30 | "format": "date-time" 31 | }, 32 | "ReportingCodeName": { 33 | "type": [ 34 | "null", 35 | "string" 36 | ] 37 | }, 38 | "SystemAccount": { 39 | "type": [ 40 | "null", 41 | "string" 42 | ] 43 | }, 44 | "BankAccountType": { 45 | "type": [ 46 | "null", 47 | "string" 48 | ] 49 | }, 50 | "TaxType": { 51 | "type": [ 52 | "null", 53 | "string" 54 | ] 55 | }, 56 | "Description": { 57 | "type": [ 58 | "null", 59 | "string" 60 | ] 61 | }, 62 | "Class": { 63 | "type": [ 64 | "null", 65 | "string" 66 | ] 67 | }, 68 | "AccountID": { 69 | "type": [ 70 | "string" 71 | ] 72 | }, 73 | "BankAccountNumber": { 74 | "type": [ 75 | "null", 76 | "string" 77 | ] 78 | }, 79 | "Status": { 80 | "type": [ 81 | "null", 82 | "string" 83 | ] 84 | }, 85 | "ShowInExpenseClaims": { 86 | "type": [ 87 | "null", 88 | "boolean" 89 | ] 90 | }, 91 | "CurrencyCode": { 92 | "type": [ 93 | "null", 94 | "string" 95 | ] 96 | }, 97 | "ReportingCode": { 98 | "type": [ 99 | "null", 100 | "string" 101 | ] 102 | }, 103 | "EnablePaymentsToAccount": { 104 | "type": [ 105 | "null", 106 | "boolean" 107 | ] 108 | }, 109 | "HasAttachments": { 110 | "type": [ 111 | "null", 112 | "boolean" 113 | ] 114 | } 115 | }, 116 | "additionalProperties": false 117 | } 118 | -------------------------------------------------------------------------------- /tap_xero/schemas/tracking_categories.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Status": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "TrackingCategoryID": { 14 | "type": [ 15 | "string" 16 | ] 17 | }, 18 | "Option": { 19 | "type": [ 20 | "null", 21 | "string" 22 | ] 23 | }, 24 | "TrackingCategoryName": { 25 | "type": [ 26 | "null", 27 | "string" 28 | ] 29 | }, 30 | "TrackingOptionID": { 31 | "type": [ 32 | "null", 33 | "string" 34 | ] 35 | }, 36 | "TrackingOptionName": { 37 | "type": [ 38 | "null", 39 | "string" 40 | ] 41 | }, 42 | "Options": { 43 | "items": { 44 | "type": [ 45 | "null", 46 | "object" 47 | ], 48 | "properties": { 49 | "IsActive": { 50 | "type": [ 51 | "null", 52 | "boolean" 53 | ] 54 | }, 55 | "IsDeleted": { 56 | "type": [ 57 | "null", 58 | "boolean" 59 | ] 60 | }, 61 | "TrackingOptionID": { 62 | "type": [ 63 | "null", 64 | "string" 65 | ] 66 | }, 67 | "IsArchived": { 68 | "type": [ 69 | "null", 70 | "boolean" 71 | ] 72 | }, 73 | "Status": { 74 | "type": [ 75 | "null", 76 | "string" 77 | ] 78 | }, 79 | "Name": { 80 | "type": [ 81 | "null", 82 | "string" 83 | ] 84 | }, 85 | "HasValidationErrors": { 86 | "type": [ 87 | "null", 88 | "boolean" 89 | ] 90 | } 91 | }, 92 | "additionalProperties": false 93 | }, 94 | "type": [ 95 | "null", 96 | "array" 97 | ] 98 | }, 99 | "Name": { 100 | "type": [ 101 | "null", 102 | "string" 103 | ] 104 | } 105 | }, 106 | "additionalProperties": false 107 | } 108 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: 218546966473.dkr.ecr.us-east-1.amazonaws.com/circle-ci:stitch-tap-tester-uv 6 | steps: 7 | - checkout 8 | - run: 9 | name: 'Setup virtual env' 10 | command: | 11 | uv venv --python 3.9 /usr/local/share/virtualenvs/tap-xero 12 | source /usr/local/share/virtualenvs/tap-xero/bin/activate 13 | uv pip install -U 'pip<19.2' 'setuptools<51.0.0' 14 | uv pip install .[dev] 15 | - run: 16 | name: 'JSON Validator' 17 | command: | 18 | source /usr/local/share/virtualenvs/tap-tester/bin/activate 19 | stitch-validate-json tap_xero/schemas/*.json 20 | - run: 21 | name: 'pylint' 22 | command: | 23 | source /usr/local/share/virtualenvs/tap-xero/bin/activate 24 | pylint tap_xero --disable 'consider-using-f-string,broad-except,chained-comparison,empty-docstring,fixme,invalid-name,line-too-long,missing-class-docstring,missing-function-docstring,missing-module-docstring,no-else-raise,no-else-return,too-few-public-methods,too-many-arguments,too-many-branches,too-many-lines,too-many-locals,ungrouped-imports,wrong-spelling-in-comment,wrong-spelling-in-docstring,bad-whitespace,unspecified-encoding,broad-exception-raised,use-dict-literal' 25 | - run: 26 | name: 'Unit Tests' 27 | command: | 28 | source /usr/local/share/virtualenvs/tap-xero/bin/activate 29 | uv pip install pytest 30 | pytest tests/unittests/ 31 | - run: 32 | name: 'Integration Tests' 33 | command: | 34 | source /usr/local/share/virtualenvs/tap-tester/bin/activate 35 | uv pip install --upgrade awscli 36 | aws s3 cp s3://com-stitchdata-dev-deployment-assets/environments/tap-tester/tap_tester_sandbox dev_env.sh 37 | source dev_env.sh 38 | run-test --tap=tap-xero tests 39 | 40 | 41 | workflows: 42 | version: 2 43 | commit: 44 | jobs: 45 | - build: 46 | context: circleci-user 47 | build_daily: 48 | triggers: 49 | - schedule: 50 | cron: "0 0,12,16 * * *" 51 | filters: 52 | branches: 53 | only: 54 | - master 55 | jobs: 56 | - build: 57 | context: circleci-user 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tap-xero 2 | 3 | This is a [Singer](https://singer.io) tap that produces JSON-formatted data 4 | following the [Singer 5 | spec](https://github.com/singer-io/getting-started/blob/master/SPEC.md). 6 | 7 | This tap: 8 | 9 | - Pulls raw data from Xero's [API](https://developer.xero.com/documentation/) 10 | - Extracts the following resources from Xero 11 | - [Bank Transactions](https://developer.xero.com/documentation/api/banktransactions) 12 | - [Contacts](https://developer.xero.com/documentation/api/contacts) 13 | - [Credit Notes](https://developer.xero.com/documentation/api/credit-notes) 14 | - [Invoices](https://developer.xero.com/documentation/api/invoices) 15 | - [Manual Journals](https://developer.xero.com/documentation/api/manual-journals) 16 | - [Overpayments](https://developer.xero.com/documentation/api/overpayments) 17 | - [Prepayments](https://developer.xero.com/documentation/api/prepayments) 18 | - [Purchase Orders](https://developer.xero.com/documentation/api/purchase-orders) 19 | - [Journals](https://developer.xero.com/documentation/api/journals) 20 | - [Accounts](https://developer.xero.com/documentation/api/accounts) 21 | - [Bank Transfers](https://developer.xero.com/documentation/api/bank-transfers) 22 | - [Employees](https://developer.xero.com/documentation/api/employees) 23 | - [Expense Claims](https://developer.xero.com/documentation/api/expense-claims) 24 | - [Items](https://developer.xero.com/documentation/api/items) 25 | - [Payments](https://developer.xero.com/documentation/api/payments) 26 | - [Receipts](https://developer.xero.com/documentation/api/receipts) 27 | - [Users](https://developer.xero.com/documentation/api/users) 28 | - [Branding Themes](https://developer.xero.com/documentation/api/branding-themes) 29 | - [Contact Groups](https://developer.xero.com/documentation/api/contactgroups) 30 | - [Currencies](https://developer.xero.com/documentation/api/currencies) 31 | - [Organisations](https://developer.xero.com/documentation/api/organisation) 32 | - [Repeating Invoices](https://developer.xero.com/documentation/api/repeating-invoices) 33 | - [Tax Rates](https://developer.xero.com/documentation/api/tax-rates) 34 | - [Tracking Categories](https://developer.xero.com/documentation/api/tracking-categories) 35 | - [Linked Transactions](https://developer.xero.com/documentation/api/linked-transactions) 36 | - Outputs the schema for each resource 37 | - Incrementally pulls data based on the input state 38 | 39 | ## Limitations 40 | 41 | - Only designed to work with Xero [Partner Applications](https://developer.xero.com/documentation/auth-and-limits/partner-applications), not Private Applications. 42 | 43 | --- 44 | 45 | Copyright © 2017 Stitch 46 | -------------------------------------------------------------------------------- /tap_xero/transform.py: -------------------------------------------------------------------------------- 1 | def _format_nested_invoice(invoice): 2 | invoice.pop("Prepayments", None) 3 | invoice.pop("Payments", None) 4 | invoice.pop("CreditNotes", None) 5 | invoice.pop("Overpayments", None) 6 | 7 | 8 | def _format_allocations(allocations): 9 | for allocation in allocations: 10 | invoice = allocation.get("Invoice", {}) 11 | _format_nested_invoice(invoice) 12 | 13 | 14 | def format_credit_notes(credit_notes): 15 | for credit_note in credit_notes: 16 | credit_note.pop("Payments", None) 17 | _format_allocations(credit_note.get("Allocations", [])) 18 | 19 | 20 | def format_contact_groups(contact_groups): 21 | for contact_group in contact_groups: 22 | contact_group.pop("Contacts", None) 23 | 24 | 25 | def format_payments(payments): 26 | for payment in payments: 27 | invoice = payment.get("Invoice", {}) 28 | _format_nested_invoice(invoice) 29 | 30 | 31 | def format_over_pre_payments(over_pre_payments): 32 | for payment in over_pre_payments: 33 | _format_allocations(payment.get("Allocations", [])) 34 | 35 | 36 | def strip_warnings(records): 37 | for record in records: 38 | record.pop("Warnings", None) 39 | 40 | format_users = strip_warnings 41 | 42 | 43 | def format_receipts(receipts): 44 | strip_warnings(receipts) 45 | for receipt in receipts: 46 | receipt.get("User", {}).pop("Warnings", None) 47 | receipt.get("Contact", {}).pop("Warnings", None) 48 | 49 | 50 | def format_contacts(contacts): 51 | strip_warnings(contacts) 52 | for contact in contacts: 53 | format_contact_groups(contact["ContactGroups"]) 54 | 55 | def format_invoices(invoices): 56 | # NB: Xero sometimes formats the Date as '/Date(0+0000)/' to indicate 57 | # it is 0 milliseconds from the unix epoch. Convert this to a datetime 58 | # that will be accepted by the transformer. This should not cause 59 | # inconsitencies because the 'Date' is normally returned as an iso8601 60 | # string and this edge case causes it to be returned differently 61 | for invoice in invoices: 62 | if invoice.get('Date') == '/Date(0+0000)/': 63 | invoice['Date'] = '1970-01-01T00:00:00.000000Z' 64 | 65 | 66 | def format_journals(journals): 67 | # NB: Xero sometimes formats the JournalDate as '/Date(0+0000)/' to 68 | # indicate it is 0 milliseconds from the unix epoch. Convert this to a 69 | # datetime that will be accepted by the transformer. This should not 70 | # cause inconsitencies because the 'Date' is normally returned as an 71 | # iso8601 string and this edge case causes it to be returned 72 | # differently 73 | for journal in journals: 74 | if journal.get('JournalDate') == '/Date(0+0000)/': 75 | journal['JournalDate'] = '1970-01-01T00:00:00.000000Z' 76 | -------------------------------------------------------------------------------- /tap_xero/schemas/receipts.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Date": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ], 12 | "format": "date-time" 13 | }, 14 | "Contact": { 15 | "$ref": "contacts" 16 | }, 17 | "LineItems": { 18 | "type": [ 19 | "null", 20 | "array" 21 | ], 22 | "items": { 23 | "$ref": "line_items" 24 | } 25 | }, 26 | "User": { 27 | "$ref": "users" 28 | }, 29 | "Reference": { 30 | "type": [ 31 | "null", 32 | "string" 33 | ] 34 | }, 35 | "LineAmountTypes": { 36 | "type": [ 37 | "null", 38 | "string" 39 | ] 40 | }, 41 | "SubTotal": { 42 | "type": [ 43 | "null", 44 | "number" 45 | ], 46 | "minimum": -1e+33, 47 | "maximum": 1e+33, 48 | "multipleOf": 1e-05, 49 | "exclusiveMinimum": true, 50 | "exclusiveMaximum": true 51 | }, 52 | "TotalTax": { 53 | "type": [ 54 | "null", 55 | "number" 56 | ], 57 | "minimum": -1e+33, 58 | "maximum": 1e+33, 59 | "multipleOf": 1e-05, 60 | "exclusiveMinimum": true, 61 | "exclusiveMaximum": true 62 | }, 63 | "Total": { 64 | "type": [ 65 | "null", 66 | "number" 67 | ], 68 | "minimum": -1e+33, 69 | "maximum": 1e+33, 70 | "multipleOf": 1e-05, 71 | "exclusiveMinimum": true, 72 | "exclusiveMaximum": true 73 | }, 74 | "ReceiptID": { 75 | "type": [ 76 | "string" 77 | ] 78 | }, 79 | "Status": { 80 | "type": [ 81 | "null", 82 | "string" 83 | ] 84 | }, 85 | "ReceiptNumber": { 86 | "type": [ 87 | "null", 88 | "integer" 89 | ] 90 | }, 91 | "UpdatedDateUTC": { 92 | "type": [ 93 | "null", 94 | "string" 95 | ], 96 | "format": "date-time" 97 | }, 98 | "HasAttachments": { 99 | "type": [ 100 | "null", 101 | "boolean" 102 | ] 103 | }, 104 | "Url": { 105 | "type": [ 106 | "null", 107 | "string" 108 | ] 109 | }, 110 | "ID": { 111 | "type": [ 112 | "null", 113 | "string" 114 | ] 115 | }, 116 | "ValidationErrors": { 117 | "$ref": "validation_errors" 118 | }, 119 | "Attachments": { 120 | "$ref": "attachments" 121 | } 122 | }, 123 | "tap_schema_dependencies": [ 124 | "contacts", 125 | "line_items", 126 | "users", 127 | "validation_errors", 128 | "attachments" 129 | ], 130 | "additionalProperties": false 131 | } 132 | -------------------------------------------------------------------------------- /tap_xero/schemas/line_items.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Description": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "Quantity": { 14 | "type": [ 15 | "null", 16 | "number" 17 | ], 18 | "minimum": -1e+33, 19 | "maximum": 1e+33, 20 | "multipleOf": 1e-05, 21 | "exclusiveMinimum": true, 22 | "exclusiveMaximum": true 23 | }, 24 | "UnitAmount": { 25 | "type": [ 26 | "null", 27 | "number" 28 | ], 29 | "minimum": -1e+33, 30 | "maximum": 1e+33, 31 | "multipleOf": 1e-05, 32 | "exclusiveMinimum": true, 33 | "exclusiveMaximum": true 34 | }, 35 | "AccountCode": { 36 | "type": [ 37 | "null", 38 | "string" 39 | ] 40 | }, 41 | "ItemCode": { 42 | "type": [ 43 | "null", 44 | "string" 45 | ] 46 | }, 47 | "LineItemID": { 48 | "type": [ 49 | "string" 50 | ] 51 | }, 52 | "TaxType": { 53 | "type": [ 54 | "null", 55 | "string" 56 | ] 57 | }, 58 | "LineAmount": { 59 | "type": [ 60 | "null", 61 | "number" 62 | ], 63 | "minimum": -1e+33, 64 | "maximum": 1e+33, 65 | "multipleOf": 1e-05, 66 | "exclusiveMinimum": true, 67 | "exclusiveMaximum": true 68 | }, 69 | "Taxability": { 70 | "type": [ 71 | "null", 72 | "string" 73 | ] 74 | }, 75 | "TaxBreakdown": { 76 | "items": { 77 | "$ref": "tax_breakdown_component" 78 | }, 79 | "type": [ 80 | "null", 81 | "array" 82 | ] 83 | }, 84 | "SalesTaxCodeId": { 85 | "type": [ 86 | "null", 87 | "number" 88 | ] 89 | }, 90 | "TaxAmount": { 91 | "type": [ 92 | "null", 93 | "number" 94 | ], 95 | "minimum": -1e+33, 96 | "maximum": 1e+33, 97 | "multipleOf": 1e-05, 98 | "exclusiveMinimum": true, 99 | "exclusiveMaximum": true 100 | }, 101 | "DiscountRate": { 102 | "type": [ 103 | "null", 104 | "number" 105 | ], 106 | "minimum": -1e+33, 107 | "maximum": 1e+33, 108 | "multipleOf": 1e-05, 109 | "exclusiveMinimum": true, 110 | "exclusiveMaximum": true 111 | }, 112 | "Tracking": { 113 | "items": { 114 | "$ref": "tracking_categories" 115 | }, 116 | "type": [ 117 | "null", 118 | "array" 119 | ] 120 | } 121 | }, 122 | "tap_schema_dependencies": [ 123 | "tracking_categories", 124 | "tax_breakdown_component" 125 | ], 126 | "additionalProperties": false 127 | } 128 | -------------------------------------------------------------------------------- /tests/test_archived_contacts_support.py: -------------------------------------------------------------------------------- 1 | import tap_tester.menagerie as menagerie 2 | import tap_tester.runner as runner 3 | import tap_tester.connections as connections 4 | 5 | from base import XeroScenarioBase, preserve_refresh_token 6 | 7 | class TestArchivedContacts(XeroScenarioBase): 8 | 9 | def setUp(self): 10 | self.include_archived_contacts = None 11 | super().setUp() 12 | 13 | def name(self): 14 | return "tap_tester_xero_common_connection" 15 | 16 | def get_properties(self): 17 | properties = super().get_properties() 18 | 19 | # include_archived_contacts is an optional property for configuration 20 | if self.include_archived_contacts: 21 | properties["include_archived_contacts"] = self.include_archived_contacts 22 | 23 | return properties 24 | 25 | def get_records_from_xero_platform(self): 26 | only_active_contacts = runner.run_check_job_and_check_status(self) 27 | 28 | found_catalogs = menagerie.get_catalogs(self.conn_id) 29 | self.check_all_streams_in_catalogs(found_catalogs) 30 | # Replicating the contacts stream 31 | self.select_specific_catalog(found_catalogs, "contacts") 32 | 33 | # clear state and run the actual sync 34 | menagerie.set_state(self.conn_id, {}) 35 | runner.run_sync_job_and_check_status(self) 36 | records = runner.get_upserts_from_target_output() 37 | 38 | return records 39 | 40 | 41 | def test_get_archived_contacts(self): 42 | # Tap-Xero be default will collect only active records 43 | only_active_records = self.get_records_from_xero_platform() 44 | contacts_status_1 = [record["ContactStatus"] for record in only_active_records] 45 | number_of_contacts_received_1 = len(only_active_records) 46 | 47 | # Verifying that no ARCHIVED contacts are returned 48 | self.assertEqual(True, "ARCHIVED" not in contacts_status_1) 49 | self.assertSetEqual({"ACTIVE"}, set(contacts_status_1)) 50 | 51 | # Configuring the tap to collect Archived records as well 52 | self.include_archived_contacts = "true" 53 | self.conn_id = connections.ensure_connection(self, payload_hook=preserve_refresh_token) 54 | # Tap-Xero be default should now collect Active and archived records 55 | active_and_archived_records = self.get_records_from_xero_platform() 56 | contacts_status_2 = [record["ContactStatus"] for record in active_and_archived_records] 57 | number_of_contacts_received_2 = len(active_and_archived_records) 58 | 59 | # Verifying that ARCHIVED and ACTIVE contacts are returned 60 | self.assertSetEqual({"ACTIVE", "ARCHIVED"}, set(contacts_status_2)) 61 | 62 | # Verifying that second sync provided more contacts then the first sync 63 | self.assertGreater(number_of_contacts_received_2, number_of_contacts_received_1) 64 | -------------------------------------------------------------------------------- /tap_xero/schemas/tax_rates.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Name": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "TaxType": { 14 | "type": [ 15 | "string" 16 | ] 17 | }, 18 | "TaxComponents": { 19 | "type": [ 20 | "null", 21 | "array" 22 | ], 23 | "items": { 24 | "type": [ 25 | "null", 26 | "object" 27 | ], 28 | "properties": { 29 | "Name": { 30 | "type": [ 31 | "null", 32 | "string" 33 | ] 34 | }, 35 | "IsCompound": { 36 | "type": [ 37 | "null", 38 | "boolean" 39 | ] 40 | }, 41 | "IsNonRecoverable": { 42 | "type": [ 43 | "null", 44 | "boolean" 45 | ] 46 | }, 47 | "Rate": { 48 | "type": [ 49 | "null", 50 | "number" 51 | ], 52 | "minimum": -1e+33, 53 | "maximum": 1e+33, 54 | "multipleOf": 1e-05, 55 | "exclusiveMinimum": true, 56 | "exclusiveMaximum": true 57 | } 58 | }, 59 | "additionalProperties": false 60 | } 61 | }, 62 | "Status": { 63 | "type": [ 64 | "null", 65 | "string" 66 | ] 67 | }, 68 | "ReportTaxType": { 69 | "type": [ 70 | "null", 71 | "string" 72 | ] 73 | }, 74 | "CanApplyToAssets": { 75 | "type": [ 76 | "null", 77 | "boolean" 78 | ] 79 | }, 80 | "CanApplyToEquity": { 81 | "type": [ 82 | "null", 83 | "boolean" 84 | ] 85 | }, 86 | "CanApplyToExpenses": { 87 | "type": [ 88 | "null", 89 | "boolean" 90 | ] 91 | }, 92 | "CanApplyToLiabilities": { 93 | "type": [ 94 | "null", 95 | "boolean" 96 | ] 97 | }, 98 | "CanApplyToRevenue": { 99 | "type": [ 100 | "null", 101 | "boolean" 102 | ] 103 | }, 104 | "DisplayTaxRate": { 105 | "type": [ 106 | "null", 107 | "number" 108 | ], 109 | "minimum": -1e+33, 110 | "maximum": 1e+33, 111 | "multipleOf": 1e-05, 112 | "exclusiveMinimum": true, 113 | "exclusiveMaximum": true 114 | }, 115 | "EffectiveRate": { 116 | "type": [ 117 | "null", 118 | "number" 119 | ], 120 | "minimum": -1e+33, 121 | "maximum": 1e+33, 122 | "multipleOf": 1e-05, 123 | "exclusiveMinimum": true, 124 | "exclusiveMaximum": true 125 | } 126 | }, 127 | "additionalProperties": false 128 | } 129 | -------------------------------------------------------------------------------- /tests/test_xero_future_dates_no_data.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from singer import utils 3 | 4 | import tap_tester.menagerie as menagerie 5 | import tap_tester.runner as runner 6 | 7 | from base import XeroScenarioBase 8 | 9 | class XeroFutureDatesNoData(XeroScenarioBase): 10 | def name(self): 11 | return "tap_tester_xero_common_connection" 12 | 13 | @property 14 | def state(self): 15 | future_dt = datetime(2050, 1, 1, tzinfo=timezone.utc) 16 | future_date = utils.strftime(future_dt) 17 | return { 18 | "currently_syncing": None, 19 | "bookmarks": { 20 | "bank_transactions": {"UpdatedDateUTC": future_date}, 21 | "contacts": {"UpdatedDateUTC": future_date}, 22 | "credit_notes": {"UpdatedDateUTC": future_date}, 23 | "invoices": {"UpdatedDateUTC": future_date}, 24 | "manual_journals": {"UpdatedDateUTC": future_date}, 25 | "overpayments": {"UpdatedDateUTC": future_date}, 26 | "prepayments": {"UpdatedDateUTC": future_date}, 27 | "purchase_orders": {"UpdatedDateUTC": future_date}, 28 | "journals": {"JournalNumber": 10e10}, 29 | "accounts": {"UpdatedDateUTC": future_date}, 30 | "bank_transfers": {"CreatedDateUTC": future_date}, 31 | "employees": {"UpdatedDateUTC": future_date}, 32 | "expense_claims": {"UpdatedDateUTC": future_date}, 33 | "items": {"UpdatedDateUTC": future_date}, 34 | "payments": {"UpdatedDateUTC": future_date}, 35 | "receipts": {"UpdatedDateUTC": future_date}, 36 | "users": {"UpdatedDateUTC": future_date}, 37 | "linked_transactions": {"UpdatedDateUTC": future_date}, 38 | "quotes": {"UpdatedDateUTC": future_date} 39 | } 40 | } 41 | 42 | def test_run(self): 43 | runner.run_check_job_and_check_status(self) 44 | found_catalogs = menagerie.get_catalogs(self.conn_id) 45 | self.select_found_catalogs(found_catalogs) 46 | 47 | # clear state and run the actual sync 48 | menagerie.set_state(self.conn_id, self.state) 49 | runner.run_sync_job_and_check_status(self) 50 | 51 | counts_by_stream = runner.examine_target_output_file( 52 | self, self.conn_id, self.expected_streams, self.expected_pks) 53 | for stream in self.state["bookmarks"]: 54 | if stream == 'journals': 55 | # Seems like this endpoint used to return nothing if the 56 | # offset was high enough, but that is not the case anymore 57 | # and we get a page of journals back 58 | continue 59 | record_count = counts_by_stream.get(stream, 0) 60 | self.assertEqual( 61 | record_count, 0, 62 | msg=("Stream {} had {} rows instead of 0" 63 | .format(stream, record_count)) 64 | ) 65 | -------------------------------------------------------------------------------- /tap_xero/schemas/items.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "ItemID": { 8 | "type": [ 9 | "string" 10 | ] 11 | }, 12 | "Code": { 13 | "type": [ 14 | "null", 15 | "string" 16 | ] 17 | }, 18 | "Name": { 19 | "type": [ 20 | "null", 21 | "string" 22 | ] 23 | }, 24 | "IsSold": { 25 | "type": [ 26 | "null", 27 | "boolean" 28 | ] 29 | }, 30 | "IsPurchased": { 31 | "type": [ 32 | "null", 33 | "boolean" 34 | ] 35 | }, 36 | "Description": { 37 | "type": [ 38 | "null", 39 | "string" 40 | ] 41 | }, 42 | "PurchaseDescription": { 43 | "type": [ 44 | "null", 45 | "string" 46 | ] 47 | }, 48 | "PurchaseDetails": { 49 | "properties": { 50 | "TaxType": { 51 | "type": [ 52 | "null", 53 | "string" 54 | ] 55 | }, 56 | "COGSAccountCode": { 57 | "type": [ 58 | "null", 59 | "string" 60 | ] 61 | }, 62 | "UnitPrice": { 63 | "type": [ 64 | "null", 65 | "number" 66 | ], 67 | "minimum": -1e+33, 68 | "maximum": 1e+33, 69 | "multipleOf": 1e-05, 70 | "exclusiveMinimum": true, 71 | "exclusiveMaximum": true 72 | }, 73 | "AccountCode": { 74 | "type": [ 75 | "null", 76 | "string" 77 | ] 78 | } 79 | }, 80 | "type": [ 81 | "null", 82 | "object" 83 | ], 84 | "additionalProperties": false 85 | }, 86 | "SalesDetails": { 87 | "properties": { 88 | "TaxType": { 89 | "type": [ 90 | "null", 91 | "string" 92 | ] 93 | }, 94 | "UnitPrice": { 95 | "type": [ 96 | "null", 97 | "number" 98 | ], 99 | "minimum": -1e+33, 100 | "maximum": 1e+33, 101 | "multipleOf": 1e-05, 102 | "exclusiveMinimum": true, 103 | "exclusiveMaximum": true 104 | }, 105 | "AccountCode": { 106 | "type": [ 107 | "null", 108 | "string" 109 | ] 110 | } 111 | }, 112 | "type": [ 113 | "null", 114 | "object" 115 | ], 116 | "additionalProperties": false 117 | }, 118 | "IsTrackedAsInventory": { 119 | "type": [ 120 | "null", 121 | "boolean" 122 | ] 123 | }, 124 | "InventoryAssetAccountCode": { 125 | "type": [ 126 | "null", 127 | "string" 128 | ] 129 | }, 130 | "TotalCostPool": { 131 | "type": [ 132 | "null", 133 | "number" 134 | ] 135 | }, 136 | "QuantityOnHand": { 137 | "type": [ 138 | "null", 139 | "number" 140 | ] 141 | }, 142 | "UpdatedDateUTC": { 143 | "type": [ 144 | "null", 145 | "string" 146 | ], 147 | "format": "date-time" 148 | } 149 | }, 150 | "additionalProperties": false 151 | } 152 | -------------------------------------------------------------------------------- /tap_xero/schemas/journals.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "JournalID": { 8 | "type": [ 9 | "string" 10 | ] 11 | }, 12 | "JournalDate": { 13 | "format": "date-time", 14 | "type": [ 15 | "null", 16 | "string" 17 | ] 18 | }, 19 | "JournalNumber": { 20 | "type": [ 21 | "null", 22 | "integer" 23 | ] 24 | }, 25 | "CreatedDateUTC": { 26 | "format": "date-time", 27 | "type": [ 28 | "null", 29 | "string" 30 | ] 31 | }, 32 | "Reference": { 33 | "type": [ 34 | "null", 35 | "string" 36 | ] 37 | }, 38 | "SourceID": { 39 | "type": [ 40 | "null", 41 | "string" 42 | ] 43 | }, 44 | "SourceType": { 45 | "type": [ 46 | "null", 47 | "string" 48 | ] 49 | }, 50 | "JournalLines": { 51 | "items": { 52 | "properties": { 53 | "JournalLineID": { 54 | "type": [ 55 | "null", 56 | "string" 57 | ] 58 | }, 59 | "AccountType": { 60 | "type": [ 61 | "null", 62 | "string" 63 | ] 64 | }, 65 | "AccountID": { 66 | "type": [ 67 | "null", 68 | "string" 69 | ] 70 | }, 71 | "AccountCode": { 72 | "type": [ 73 | "null", 74 | "string" 75 | ] 76 | }, 77 | "TaxName": { 78 | "type": [ 79 | "null", 80 | "string" 81 | ] 82 | }, 83 | "TaxType": { 84 | "type": [ 85 | "null", 86 | "string" 87 | ] 88 | }, 89 | "Description": { 90 | "type": [ 91 | "null", 92 | "string" 93 | ] 94 | }, 95 | "GrossAmount": { 96 | "type": [ 97 | "null", 98 | "number" 99 | ] 100 | }, 101 | "NetAmount": { 102 | "type": [ 103 | "null", 104 | "number" 105 | ] 106 | }, 107 | "AccountName": { 108 | "type": [ 109 | "null", 110 | "string" 111 | ] 112 | }, 113 | "TaxAmount": { 114 | "type": [ 115 | "null", 116 | "number" 117 | ] 118 | }, 119 | "TrackingCategories": { 120 | "items": { 121 | "$ref": "tracking_categories" 122 | }, 123 | "type": [ 124 | "null", 125 | "array" 126 | ] 127 | } 128 | }, 129 | "type": [ 130 | "null", 131 | "object" 132 | ], 133 | "additionalProperties": false 134 | }, 135 | "type": [ 136 | "null", 137 | "array" 138 | ] 139 | } 140 | }, 141 | "tap_schema_dependencies": [ 142 | "tracking_categories" 143 | ], 144 | "additionalProperties": false 145 | } 146 | -------------------------------------------------------------------------------- /tap_xero/schemas/manual_journals.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Date": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ], 12 | "format": "date-time" 13 | }, 14 | "LineAmountTypes": { 15 | "type": [ 16 | "null", 17 | "string" 18 | ] 19 | }, 20 | "Status": { 21 | "type": [ 22 | "null", 23 | "string" 24 | ] 25 | }, 26 | "Narration": { 27 | "type": [ 28 | "null", 29 | "string" 30 | ] 31 | }, 32 | "JournalLines": { 33 | "type": [ 34 | "null", 35 | "array" 36 | ], 37 | "items": { 38 | "type": [ 39 | "null", 40 | "object" 41 | ], 42 | "properties": { 43 | "LineAmount": { 44 | "type": [ 45 | "null", 46 | "number" 47 | ], 48 | "minimum": -1e+33, 49 | "maximum": 1e+33, 50 | "multipleOf": 1e-05, 51 | "exclusiveMinimum": true, 52 | "exclusiveMaximum": true 53 | }, 54 | "Description": { 55 | "type": [ 56 | "null", 57 | "string" 58 | ] 59 | }, 60 | "TaxAmount": { 61 | "type": [ 62 | "null", 63 | "number" 64 | ], 65 | "minimum": -1e+33, 66 | "maximum": 1e+33, 67 | "multipleOf": 1e-05, 68 | "exclusiveMinimum": true, 69 | "exclusiveMaximum": true 70 | }, 71 | "AccountID": { 72 | "type": [ 73 | "null", 74 | "string" 75 | ] 76 | }, 77 | "AccountCode": { 78 | "type": [ 79 | "null", 80 | "string" 81 | ] 82 | }, 83 | "IsBlank": { 84 | "type": [ 85 | "null", 86 | "boolean" 87 | ] 88 | }, 89 | "TaxType": { 90 | "type": [ 91 | "null", 92 | "string" 93 | ] 94 | }, 95 | "Tracking": { 96 | "items": { 97 | "$ref": "tracking_categories" 98 | }, 99 | "type": [ 100 | "null", 101 | "array" 102 | ] 103 | } 104 | }, 105 | "additionalProperties": false 106 | } 107 | }, 108 | "Url": { 109 | "type": [ 110 | "null", 111 | "string" 112 | ] 113 | }, 114 | "ShowOnCashBasisReports": { 115 | "type": [ 116 | "null", 117 | "boolean" 118 | ] 119 | }, 120 | "HasAttachments": { 121 | "type": [ 122 | "null", 123 | "boolean" 124 | ] 125 | }, 126 | "UpdatedDateUTC": { 127 | "type": [ 128 | "null", 129 | "string" 130 | ], 131 | "format": "date-time" 132 | }, 133 | "ManualJournalID": { 134 | "type": [ 135 | "string" 136 | ] 137 | } 138 | }, 139 | "tap_schema_dependencies": [ 140 | "tracking_categories" 141 | ], 142 | "additionalProperties": false 143 | } 144 | -------------------------------------------------------------------------------- /tap_xero/schemas/bank_transactions.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Type": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "Contact": { 14 | "$ref": "contacts" 15 | }, 16 | "LineItems": { 17 | "type": [ 18 | "null", 19 | "array" 20 | ], 21 | "items": { 22 | "$ref": "line_items" 23 | } 24 | }, 25 | "BankAccount": { 26 | "$ref": "accounts" 27 | }, 28 | "IsReconciled": { 29 | "type": [ 30 | "null", 31 | "boolean" 32 | ] 33 | }, 34 | "Date": { 35 | "type": [ 36 | "null", 37 | "string" 38 | ], 39 | "format": "date-time" 40 | }, 41 | "DateString": { 42 | "type": [ 43 | "null", 44 | "string" 45 | ], 46 | "format": "date-time" 47 | }, 48 | "Reference": { 49 | "type": [ 50 | "null", 51 | "string" 52 | ] 53 | }, 54 | "CurrencyCode": { 55 | "type": [ 56 | "null", 57 | "string" 58 | ] 59 | }, 60 | "CurrencyRate": { 61 | "type": [ 62 | "null", 63 | "number" 64 | ], 65 | "minimum": -1e+33, 66 | "maximum": 1e+33, 67 | "multipleOf": 1e-10, 68 | "exclusiveMinimum": true, 69 | "exclusiveMaximum": true 70 | }, 71 | "Url": { 72 | "type": [ 73 | "null", 74 | "string" 75 | ] 76 | }, 77 | "Status": { 78 | "type": [ 79 | "null", 80 | "string" 81 | ] 82 | }, 83 | "LineAmountTypes": { 84 | "type": [ 85 | "null", 86 | "string" 87 | ] 88 | }, 89 | "SubTotal": { 90 | "type": [ 91 | "null", 92 | "number" 93 | ], 94 | "minimum": -1e+33, 95 | "maximum": 1e+33, 96 | "multipleOf": 1e-06, 97 | "exclusiveMinimum": true, 98 | "exclusiveMaximum": true 99 | }, 100 | "TotalTax": { 101 | "type": [ 102 | "null", 103 | "number" 104 | ], 105 | "minimum": -1e+33, 106 | "maximum": 1e+33, 107 | "multipleOf": 1e-06, 108 | "exclusiveMinimum": true, 109 | "exclusiveMaximum": true 110 | }, 111 | "Total": { 112 | "type": [ 113 | "null", 114 | "number" 115 | ], 116 | "minimum": -1e+33, 117 | "maximum": 1e+33, 118 | "multipleOf": 1e-06, 119 | "exclusiveMinimum": true, 120 | "exclusiveMaximum": true 121 | }, 122 | "BankTransactionID": { 123 | "type": [ 124 | "string" 125 | ] 126 | }, 127 | "PrepaymentID": { 128 | "type": [ 129 | "null", 130 | "string" 131 | ] 132 | }, 133 | "OverpaymentID": { 134 | "type": [ 135 | "null", 136 | "string" 137 | ] 138 | }, 139 | "UpdatedDateUTC": { 140 | "type": [ 141 | "null", 142 | "string" 143 | ], 144 | "format": "date-time" 145 | }, 146 | "HasAttachments": { 147 | "type": [ 148 | "null", 149 | "boolean" 150 | ] 151 | }, 152 | "ExternalLinkProviderName": { 153 | "type": [ 154 | "null", 155 | "string" 156 | ] 157 | } 158 | }, 159 | "tap_schema_dependencies": [ 160 | "accounts", 161 | "contacts", 162 | "line_items" 163 | ], 164 | "additionalProperties": false 165 | } 166 | -------------------------------------------------------------------------------- /tests/test_xero_bookmarks.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import tap_tester.menagerie as menagerie 4 | import tap_tester.runner as runner 5 | 6 | from base import XeroScenarioBase 7 | 8 | class XeroBookmarks(XeroScenarioBase): 9 | def name(self): 10 | return "tap_tester_xero_common_connection" 11 | 12 | def check_output_record_counts(self): 13 | counts_by_stream = runner.examine_target_output_file( 14 | self, self.conn_id, self.expected_streams, self.expected_pks) 15 | replicated_row_count = sum(counts_by_stream.values()) 16 | self.assertGreater( 17 | replicated_row_count, 0, 18 | msg="failed to replicate any data: {}".format(counts_by_stream) 19 | ) 20 | print("total replicated row count: {}".format(replicated_row_count)) 21 | 22 | def check_bookmarks(self, bookmarks, max_bookmarks_from_records): 23 | """Checks that the bookmarks in the state match the maximum values 24 | found in the records themselves.""" 25 | # if we didn't replicate data, the bookmark should be the default 26 | for stream in self.expected_bookmarks: 27 | if stream not in max_bookmarks_from_records: 28 | max_bookmarks_from_records[stream] = self.get_bookmark_default(stream) 29 | for stream, bk_names in self.expected_bookmarks.items(): 30 | for bk_name in bk_names: 31 | bk_value = bookmarks.get(stream, {}).get(bk_name) 32 | self.assertIsNotNone( 33 | bk_value, 34 | msg="stream '{}' had no bookmark '{}' bookmarks: {}" 35 | .format(stream, bk_name, json.dumps(bookmarks, indent=2)) 36 | ) 37 | max_bk_found = max_bookmarks_from_records[stream] 38 | self.assertEqual( 39 | self.typify_bookmark(stream, bk_name, bk_value), 40 | self.typify_bookmark(stream, bk_name, max_bk_found), 41 | "Bookmark {} for stream {} should have been updated to {}" 42 | .format(bk_value, stream, max_bk_found) 43 | ) 44 | print("bookmark {}({}) updated to {}" 45 | .format(stream, bk_name, bk_value)) 46 | 47 | def check_offsets(self, bookmarks): 48 | for stream, offset in self.expected_offsets.items(): 49 | self.assertEqual( 50 | bookmarks.get(stream, {}).get("offset"), offset, 51 | msg=("unexpected offset found for stream {} {}. bookmarks: {}" 52 | .format(stream, offset, bookmarks)) 53 | ) 54 | print("offsets {} cleared".format(stream)) 55 | 56 | def test_run(self): 57 | runner.run_check_job_and_check_status(self) 58 | 59 | found_catalogs = menagerie.get_catalogs(self.conn_id) 60 | self.check_all_streams_in_catalogs(found_catalogs) 61 | self.select_found_catalogs(found_catalogs) 62 | 63 | # clear state and run the actual sync 64 | menagerie.set_state(self.conn_id, {}) 65 | runner.run_sync_job_and_check_status(self) 66 | self.check_output_record_counts() 67 | 68 | max_bookmarks_from_records = runner.get_max_bookmarks_from_target(self) 69 | state = menagerie.get_state(self.conn_id) 70 | bookmarks = state.get("bookmarks", {}) 71 | self.check_bookmarks(bookmarks, max_bookmarks_from_records) 72 | self.check_offsets(bookmarks) 73 | self.look_for_unexpected_bookmarks(bookmarks) 74 | self.assertIsNone(state.get("currently_syncing")) 75 | -------------------------------------------------------------------------------- /tap_xero/schemas/payments.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Date": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ], 12 | "format": "date-time" 13 | }, 14 | "CurrencyRate": { 15 | "type": [ 16 | "null", 17 | "number" 18 | ], 19 | "minimum": -1e+33, 20 | "maximum": 1e+33, 21 | "multipleOf": 1e-10, 22 | "exclusiveMinimum": true, 23 | "exclusiveMaximum": true 24 | }, 25 | "Amount": { 26 | "type": [ 27 | "null", 28 | "number" 29 | ], 30 | "minimum": -1e+33, 31 | "maximum": 1e+33, 32 | "multipleOf": 1e-05, 33 | "exclusiveMinimum": true, 34 | "exclusiveMaximum": true 35 | }, 36 | "Reference": { 37 | "type": [ 38 | "null", 39 | "string" 40 | ] 41 | }, 42 | "IsReconciled": { 43 | "type": [ 44 | "null", 45 | "boolean" 46 | ] 47 | }, 48 | "Status": { 49 | "type": [ 50 | "null", 51 | "string" 52 | ] 53 | }, 54 | "PaymentType": { 55 | "type": [ 56 | "null", 57 | "string" 58 | ] 59 | }, 60 | "UpdatedDateUTC": { 61 | "type": [ 62 | "null", 63 | "string" 64 | ], 65 | "format": "date-time" 66 | }, 67 | "Account": { 68 | "$ref": "accounts" 69 | }, 70 | "Invoice": { 71 | "$ref": "nested_invoice" 72 | }, 73 | "CreditNote": { 74 | "type": [ 75 | "null", 76 | "object" 77 | ], 78 | "properties": { 79 | "CreditNoteNumber": { 80 | "type": [ 81 | "string" 82 | ] 83 | } 84 | }, 85 | "additionalProperties": false 86 | }, 87 | "Prepayments": { 88 | "type": [ 89 | "null", 90 | "array" 91 | ], 92 | "items": { 93 | "type": [ 94 | "null", 95 | "object" 96 | ], 97 | "properties": { 98 | "PrepaymentID": { 99 | "type": [ 100 | "string" 101 | ] 102 | } 103 | }, 104 | "additionalProperties": false 105 | } 106 | }, 107 | "Overpayment": { 108 | "type": [ 109 | "null", 110 | "array" 111 | ], 112 | "items": { 113 | "type": [ 114 | "null", 115 | "object" 116 | ], 117 | "properties": { 118 | "OverpaymentID": { 119 | "type": [ 120 | "string" 121 | ] 122 | } 123 | }, 124 | "additionalProperties": false 125 | } 126 | }, 127 | "BankAmount": { 128 | "type": [ 129 | "null", 130 | "number" 131 | ], 132 | "minimum": -1e+33, 133 | "maximum": 1e+33, 134 | "multipleOf": 1e-05, 135 | "exclusiveMinimum": true, 136 | "exclusiveMaximum": true 137 | }, 138 | "HasValidationErrors": { 139 | "type": [ 140 | "null", 141 | "boolean" 142 | ] 143 | }, 144 | "PaymentID": { 145 | "type": [ 146 | "string" 147 | ] 148 | }, 149 | "HasAccount": { 150 | "type": [ 151 | "null", 152 | "boolean" 153 | ] 154 | }, 155 | "BatchPaymentID": { 156 | "type": [ 157 | "null", 158 | "string" 159 | ] 160 | } 161 | }, 162 | "tap_schema_dependencies": [ 163 | "accounts", 164 | "nested_invoice" 165 | ], 166 | "additionalProperties": false 167 | } 168 | -------------------------------------------------------------------------------- /tap_xero/schemas/repeating_invoices.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Type": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "Contact": { 14 | "$ref": "contacts" 15 | }, 16 | "Schedule": { 17 | "type": [ 18 | "null", 19 | "object" 20 | ], 21 | "properties": { 22 | "Unit": { 23 | "type": [ 24 | "null", 25 | "string" 26 | ] 27 | }, 28 | "DueDateType": { 29 | "type": [ 30 | "null", 31 | "string" 32 | ] 33 | }, 34 | "StartDate": { 35 | "type": [ 36 | "null", 37 | "string" 38 | ], 39 | "format": "date-time" 40 | }, 41 | "DueDate": { 42 | "type": [ 43 | "null", 44 | "integer" 45 | ] 46 | }, 47 | "EndDate": { 48 | "type": [ 49 | "null", 50 | "string" 51 | ] 52 | }, 53 | "NextScheduledDate": { 54 | "type": [ 55 | "null", 56 | "string" 57 | ], 58 | "format": "date-time" 59 | }, 60 | "Period": { 61 | "type": [ 62 | "null", 63 | "integer" 64 | ] 65 | } 66 | }, 67 | "additionalProperties": false 68 | }, 69 | "LineItems": { 70 | "items": { 71 | "$ref": "line_items" 72 | }, 73 | "type": [ 74 | "null", 75 | "array" 76 | ] 77 | }, 78 | "LineAmountTypes": { 79 | "type": [ 80 | "null", 81 | "string" 82 | ] 83 | }, 84 | "Reference": { 85 | "type": [ 86 | "null", 87 | "string" 88 | ] 89 | }, 90 | "BrandingThemeID": { 91 | "type": [ 92 | "null", 93 | "string" 94 | ] 95 | }, 96 | "CurrencyCode": { 97 | "type": [ 98 | "null", 99 | "string" 100 | ] 101 | }, 102 | "Status": { 103 | "type": [ 104 | "null", 105 | "string" 106 | ] 107 | }, 108 | "SubTotal": { 109 | "type": [ 110 | "null", 111 | "number" 112 | ], 113 | "minimum": -1e+33, 114 | "maximum": 1e+33, 115 | "multipleOf": 1e-05, 116 | "exclusiveMinimum": true, 117 | "exclusiveMaximum": true 118 | }, 119 | "TotalTax": { 120 | "type": [ 121 | "null", 122 | "number" 123 | ], 124 | "minimum": -1e+33, 125 | "maximum": 1e+33, 126 | "multipleOf": 1e-05, 127 | "exclusiveMinimum": true, 128 | "exclusiveMaximum": true 129 | }, 130 | "TotalDiscount": { 131 | "type": [ 132 | "null", 133 | "number" 134 | ], 135 | "minimum": -1e+33, 136 | "maximum": 1e+33, 137 | "multipleOf": 1e-05, 138 | "exclusiveMinimum": true, 139 | "exclusiveMaximum": true 140 | }, 141 | "Total": { 142 | "type": [ 143 | "null", 144 | "number" 145 | ], 146 | "minimum": -1e+33, 147 | "maximum": 1e+33, 148 | "multipleOf": 1e-05, 149 | "exclusiveMinimum": true, 150 | "exclusiveMaximum": true 151 | }, 152 | "RepeatingInvoiceID": { 153 | "type": [ 154 | "string" 155 | ] 156 | }, 157 | "HasAttachments": { 158 | "type": [ 159 | "null", 160 | "boolean" 161 | ] 162 | }, 163 | "ID": { 164 | "type": [ 165 | "null", 166 | "string" 167 | ] 168 | } 169 | }, 170 | "tap_schema_dependencies": [ 171 | "contacts", 172 | "line_items" 173 | ], 174 | "additionalProperties": false 175 | } 176 | -------------------------------------------------------------------------------- /tap_xero/schemas/overpayments.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Type": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "Contact": { 14 | "$ref": "contacts" 15 | }, 16 | "Date": { 17 | "type": [ 18 | "null", 19 | "string" 20 | ], 21 | "format": "date-time" 22 | }, 23 | "Status": { 24 | "type": [ 25 | "null", 26 | "string" 27 | ] 28 | }, 29 | "AppliedAmount": { 30 | "type": [ 31 | "null", 32 | "number" 33 | ], 34 | "minimum": -1e+33, 35 | "maximum": 1e+33, 36 | "multipleOf": 1e-05, 37 | "exclusiveMinimum": true, 38 | "exclusiveMaximum": true 39 | }, 40 | "LineAmountTypes": { 41 | "type": [ 42 | "null", 43 | "string" 44 | ] 45 | }, 46 | "LineItems": { 47 | "items": { 48 | "$ref": "line_items" 49 | }, 50 | "type": [ 51 | "null", 52 | "array" 53 | ] 54 | }, 55 | "SubTotal": { 56 | "type": [ 57 | "null", 58 | "number" 59 | ], 60 | "minimum": -1e+33, 61 | "maximum": 1e+33, 62 | "multipleOf": 1e-05, 63 | "exclusiveMinimum": true, 64 | "exclusiveMaximum": true 65 | }, 66 | "TotalTax": { 67 | "type": [ 68 | "null", 69 | "number" 70 | ], 71 | "minimum": -1e+33, 72 | "maximum": 1e+33, 73 | "multipleOf": 1e-05, 74 | "exclusiveMinimum": true, 75 | "exclusiveMaximum": true 76 | }, 77 | "Total": { 78 | "type": [ 79 | "null", 80 | "number" 81 | ], 82 | "minimum": -1e+33, 83 | "maximum": 1e+33, 84 | "multipleOf": 1e-05, 85 | "exclusiveMinimum": true, 86 | "exclusiveMaximum": true 87 | }, 88 | "UpdatedDateUTC": { 89 | "type": [ 90 | "null", 91 | "string" 92 | ], 93 | "format": "date-time" 94 | }, 95 | "CurrencyCode": { 96 | "type": [ 97 | "null", 98 | "string" 99 | ] 100 | }, 101 | "OverpaymentID": { 102 | "type": [ 103 | "string" 104 | ] 105 | }, 106 | "CurrencyRate": { 107 | "type": [ 108 | "null", 109 | "number" 110 | ], 111 | "minimum": -1e+33, 112 | "maximum": 1e+33, 113 | "multipleOf": 1e-10, 114 | "exclusiveMinimum": true, 115 | "exclusiveMaximum": true 116 | }, 117 | "RemainingCredit": { 118 | "type": [ 119 | "null", 120 | "number" 121 | ], 122 | "minimum": -1e+33, 123 | "maximum": 1e+33, 124 | "multipleOf": 1e-05, 125 | "exclusiveMinimum": true, 126 | "exclusiveMaximum": true 127 | }, 128 | "Allocations": { 129 | "type": [ 130 | "null", 131 | "array" 132 | ], 133 | "items": { 134 | "$ref": "allocations" 135 | } 136 | }, 137 | "Payments": { 138 | "type": [ 139 | "null", 140 | "array" 141 | ], 142 | "items": { 143 | "$ref": "payments" 144 | } 145 | }, 146 | "Reference": { 147 | "type": [ 148 | "null", 149 | "string" 150 | ] 151 | }, 152 | "HasAttachments": { 153 | "type": [ 154 | "null", 155 | "boolean" 156 | ] 157 | }, 158 | "ID": { 159 | "type": [ 160 | "null", 161 | "string" 162 | ] 163 | }, 164 | "DateString": { 165 | "type": [ 166 | "null", 167 | "string" 168 | ], 169 | "format": "date-time" 170 | } 171 | }, 172 | "tap_schema_dependencies": [ 173 | "contacts", 174 | "line_items", 175 | "payments", 176 | "allocations" 177 | ], 178 | "additionalProperties": false 179 | } 180 | -------------------------------------------------------------------------------- /tap_xero/schemas/prepayments.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Type": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "ID": { 14 | "type": [ 15 | "null", 16 | "string" 17 | ] 18 | }, 19 | "Contact": { 20 | "$ref": "contacts" 21 | }, 22 | "Date": { 23 | "format": "date-time", 24 | "type": [ 25 | "null", 26 | "string" 27 | ] 28 | }, 29 | "Status": { 30 | "type": [ 31 | "null", 32 | "string" 33 | ] 34 | }, 35 | "LineAmountTypes": { 36 | "type": [ 37 | "null", 38 | "string" 39 | ] 40 | }, 41 | "LineItems": { 42 | "items": { 43 | "$ref": "line_items" 44 | }, 45 | "type": [ 46 | "null", 47 | "array" 48 | ] 49 | }, 50 | "SubTotal": { 51 | "type": [ 52 | "null", 53 | "number" 54 | ], 55 | "minimum": -1e+33, 56 | "maximum": 1e+33, 57 | "multipleOf": 1e-05, 58 | "exclusiveMinimum": true, 59 | "exclusiveMaximum": true 60 | }, 61 | "Total": { 62 | "type": [ 63 | "null", 64 | "number" 65 | ], 66 | "minimum": -1e+33, 67 | "maximum": 1e+33, 68 | "multipleOf": 1e-05, 69 | "exclusiveMinimum": true, 70 | "exclusiveMaximum": true 71 | }, 72 | "UpdatedDateUTC": { 73 | "format": "date-time", 74 | "type": [ 75 | "null", 76 | "string" 77 | ] 78 | }, 79 | "CurrencyCode": { 80 | "type": [ 81 | "null", 82 | "string" 83 | ] 84 | }, 85 | "PrepaymentID": { 86 | "type": [ 87 | "string" 88 | ] 89 | }, 90 | "CurrencyRate": { 91 | "type": [ 92 | "null", 93 | "number" 94 | ], 95 | "minimum": -1e+33, 96 | "maximum": 1e+33, 97 | "multipleOf": 1e-10, 98 | "exclusiveMinimum": true, 99 | "exclusiveMaximum": true 100 | }, 101 | "Reference": { 102 | "type": [ 103 | "null", 104 | "string" 105 | ] 106 | }, 107 | "RemainingCredit": { 108 | "type": [ 109 | "null", 110 | "number" 111 | ], 112 | "minimum": -1e+33, 113 | "maximum": 1e+33, 114 | "multipleOf": 1e-05, 115 | "exclusiveMinimum": true, 116 | "exclusiveMaximum": true 117 | }, 118 | "AppliedAmount": { 119 | "type": [ 120 | "null", 121 | "number" 122 | ], 123 | "minimum": -1e+33, 124 | "maximum": 1e+33, 125 | "multipleOf": 1e-05, 126 | "exclusiveMinimum": true, 127 | "exclusiveMaximum": true 128 | }, 129 | "Allocations": { 130 | "type": [ 131 | "null", 132 | "array" 133 | ], 134 | "items": { 135 | "$ref": "allocations" 136 | } 137 | }, 138 | "Payments": { 139 | "type": [ 140 | "null", 141 | "array" 142 | ], 143 | "items": { 144 | "$ref": "payments" 145 | } 146 | }, 147 | "HasAttachments": { 148 | "type": [ 149 | "null", 150 | "boolean" 151 | ] 152 | }, 153 | "TotalTax": { 154 | "type": [ 155 | "null", 156 | "number" 157 | ], 158 | "minimum": -1e+33, 159 | "maximum": 1e+33, 160 | "multipleOf": 1e-05, 161 | "exclusiveMinimum": true, 162 | "exclusiveMaximum": true 163 | }, 164 | "DateString": { 165 | "format": "date-time", 166 | "type": [ 167 | "null", 168 | "string" 169 | ] 170 | } 171 | }, 172 | "tap_schema_dependencies": [ 173 | "allocations", 174 | "contacts", 175 | "line_items", 176 | "payments" 177 | ], 178 | "additionalProperties": false 179 | } 180 | -------------------------------------------------------------------------------- /tap_xero/schemas/organisations.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "APIKey": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "Name": { 14 | "type": [ 15 | "null", 16 | "string" 17 | ] 18 | }, 19 | "LegalName": { 20 | "type": [ 21 | "null", 22 | "string" 23 | ] 24 | }, 25 | "PaysTax": { 26 | "type": [ 27 | "null", 28 | "boolean" 29 | ] 30 | }, 31 | "Version": { 32 | "type": [ 33 | "null", 34 | "string" 35 | ] 36 | }, 37 | "OrganisationType": { 38 | "type": [ 39 | "null", 40 | "string" 41 | ] 42 | }, 43 | "BaseCurrency": { 44 | "type": [ 45 | "null", 46 | "string" 47 | ] 48 | }, 49 | "CountryCode": { 50 | "type": [ 51 | "null", 52 | "string" 53 | ] 54 | }, 55 | "IsDemoCompany": { 56 | "type": [ 57 | "null", 58 | "boolean" 59 | ] 60 | }, 61 | "OrganisationStatus": { 62 | "type": [ 63 | "null", 64 | "string" 65 | ] 66 | }, 67 | "RegistrationNumber": { 68 | "type": [ 69 | "null", 70 | "string" 71 | ] 72 | }, 73 | "TaxNumber": { 74 | "type": [ 75 | "null", 76 | "string" 77 | ] 78 | }, 79 | "FinancialYearEndDay": { 80 | "type": [ 81 | "null", 82 | "integer" 83 | ] 84 | }, 85 | "FinancialYearEndMonth": { 86 | "type": [ 87 | "null", 88 | "integer" 89 | ] 90 | }, 91 | "SalesTaxBasis": { 92 | "type": [ 93 | "null", 94 | "string" 95 | ] 96 | }, 97 | "SalesTaxPeriod": { 98 | "type": [ 99 | "null", 100 | "string" 101 | ] 102 | }, 103 | "DefaultSalesTax": { 104 | "type": [ 105 | "null", 106 | "string" 107 | ] 108 | }, 109 | "DefaultPurchasesTax": { 110 | "type": [ 111 | "null", 112 | "string" 113 | ] 114 | }, 115 | "PeriodLockDate": { 116 | "type": [ 117 | "null", 118 | "string" 119 | ], 120 | "format": "date-time" 121 | }, 122 | "EndOfYearLockDate": { 123 | "type": [ 124 | "null", 125 | "string" 126 | ], 127 | "format": "date-time" 128 | }, 129 | "CreatedDateUTC": { 130 | "type": [ 131 | "null", 132 | "string" 133 | ], 134 | "format": "date-time" 135 | }, 136 | "Timezone": { 137 | "type": [ 138 | "null", 139 | "string" 140 | ] 141 | }, 142 | "OrganisationEntityType": { 143 | "type": [ 144 | "null", 145 | "string" 146 | ] 147 | }, 148 | "ShortCode": { 149 | "type": [ 150 | "null", 151 | "string" 152 | ] 153 | }, 154 | "OrganisationID": { 155 | "type": [ 156 | "string" 157 | ] 158 | }, 159 | "LineOfBusiness": { 160 | "type": [ 161 | "null", 162 | "string" 163 | ] 164 | }, 165 | "Addresses": { 166 | "type": [ 167 | "null", 168 | "array" 169 | ], 170 | "items": { 171 | "$ref": "addresses" 172 | } 173 | }, 174 | "Phones": { 175 | "type": [ 176 | "null", 177 | "array" 178 | ], 179 | "items": { 180 | "$ref": "phones" 181 | } 182 | }, 183 | "ExternalLinks": { 184 | "type": [ 185 | "null", 186 | "array" 187 | ], 188 | "items": { 189 | "type": [ 190 | "null", 191 | "object" 192 | ], 193 | "properties": { 194 | "LinkType": { 195 | "type": [ 196 | "null", 197 | "string" 198 | ] 199 | }, 200 | "Url": { 201 | "type": [ 202 | "null", 203 | "string" 204 | ] 205 | } 206 | }, 207 | "additionalProperties": false 208 | } 209 | }, 210 | "PaymentTerms": { 211 | "$ref": "payment_terms" 212 | } 213 | }, 214 | "tap_schema_dependencies": [ 215 | "addresses", 216 | "phones", 217 | "payment_terms" 218 | ], 219 | "additionalProperties": false 220 | } 221 | -------------------------------------------------------------------------------- /tap_xero/schemas/quotes.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties":{ 7 | "Contact": { 8 | "$ref": "contacts" 9 | }, 10 | "Date": { 11 | "type": [ 12 | "null", 13 | "string" 14 | ], 15 | "format": "date-time" 16 | }, 17 | "ExpiryDate": { 18 | "type": [ 19 | "null", 20 | "string" 21 | ], 22 | "format": "date-time" 23 | }, 24 | "Status":{ 25 | "type":[ 26 | "null", 27 | "string" 28 | ] 29 | }, 30 | "LineAmountTypes": { 31 | "type": [ 32 | "null", 33 | "string" 34 | ] 35 | }, 36 | "LineItems": { 37 | "type": [ 38 | "null", 39 | "array" 40 | ], 41 | "items": { 42 | "$ref": "line_items" 43 | } 44 | }, 45 | "SubTotal": { 46 | "type": [ 47 | "null", 48 | "number" 49 | ], 50 | "minimum": -1e+33, 51 | "maximum": 1e+33, 52 | "multipleOf": 1e-06, 53 | "exclusiveMinimum": true, 54 | "exclusiveMaximum": true 55 | }, 56 | "TotalTax": { 57 | "type": [ 58 | "null", 59 | "number" 60 | ], 61 | "minimum": -1e+33, 62 | "maximum": 1e+33, 63 | "multipleOf": 1e-06, 64 | "exclusiveMinimum": true, 65 | "exclusiveMaximum": true 66 | }, 67 | "Total": { 68 | "type": [ 69 | "null", 70 | "number" 71 | ], 72 | "minimum": -1e+33, 73 | "maximum": 1e+33, 74 | "multipleOf": 1e-06, 75 | "exclusiveMinimum": true, 76 | "exclusiveMaximum": true 77 | }, 78 | "TotalDiscount":{ 79 | "type": [ 80 | "null", 81 | "number" 82 | ], 83 | "minimum": -1e+33, 84 | "maximum": 1e+33, 85 | "multipleOf": 1e-05, 86 | "exclusiveMinimum": true, 87 | "exclusiveMaximum": true 88 | }, 89 | "UpdatedDateUTC": { 90 | "type": [ 91 | "null", 92 | "string" 93 | ], 94 | "format": "date-time" 95 | }, 96 | "CurrencyCode": { 97 | "type": [ 98 | "null", 99 | "string" 100 | ] 101 | }, 102 | "CurrencyRate": { 103 | "type": [ 104 | "null", 105 | "number" 106 | ], 107 | "minimum": -1e+33, 108 | "maximum": 1e+33, 109 | "multipleOf": 1e-06, 110 | "exclusiveMinimum": true, 111 | "exclusiveMaximum": true 112 | }, 113 | "QuoteID":{ 114 | "type":[ 115 | "null", 116 | "string" 117 | ] 118 | }, 119 | "QuoteNumber":{ 120 | "type":[ 121 | "null", 122 | "string" 123 | ] 124 | }, 125 | "Reference": { 126 | "type": [ 127 | "null", 128 | "string" 129 | ] 130 | }, 131 | "BrandingThemeID":{ 132 | "type": [ 133 | "null", 134 | "string" 135 | ] 136 | }, 137 | "Title": { 138 | "type": [ 139 | "null", 140 | "string" 141 | ] 142 | }, 143 | "Summary": { 144 | "type": [ 145 | "null", 146 | "string" 147 | ] 148 | }, 149 | "Terms": { 150 | "type": [ 151 | "null", 152 | "string" 153 | ] 154 | }, 155 | "TrackingCategory": { 156 | "type": [ 157 | "null", 158 | "array" 159 | ], 160 | "items": { 161 | "$ref": "tracking_categories" 162 | } 163 | } 164 | }, 165 | "tap_schema_dependencies": [ 166 | "contacts", 167 | "line_items", 168 | "tracking_categories" 169 | ], 170 | "additionalProperties": false 171 | } 172 | -------------------------------------------------------------------------------- /tap_xero/schemas/credit_notes.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Type": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "Contact": { 14 | "$ref": "contacts" 15 | }, 16 | "Date": { 17 | "format": "date-time", 18 | "type": [ 19 | "null", 20 | "string" 21 | ] 22 | }, 23 | "DueDate": { 24 | "format": "date-time", 25 | "type": [ 26 | "null", 27 | "string" 28 | ] 29 | }, 30 | "DueDateString": { 31 | "format": "date-time", 32 | "type": [ 33 | "null", 34 | "string" 35 | ] 36 | }, 37 | "Status": { 38 | "type": [ 39 | "null", 40 | "string" 41 | ] 42 | }, 43 | "LineAmountTypes": { 44 | "type": [ 45 | "null", 46 | "string" 47 | ] 48 | }, 49 | "LineItems": { 50 | "items": { 51 | "$ref": "line_items" 52 | }, 53 | "type": [ 54 | "null", 55 | "array" 56 | ] 57 | }, 58 | "InvoiceAddresses": { 59 | "items": { 60 | "$ref": "invoice_address" 61 | }, 62 | "type": [ 63 | "null", 64 | "array" 65 | ] 66 | }, 67 | "SubTotal": { 68 | "type": [ 69 | "null", 70 | "number" 71 | ], 72 | "minimum": -1e+33, 73 | "maximum": 1e+33, 74 | "multipleOf": 1e-05, 75 | "exclusiveMinimum": true, 76 | "exclusiveMaximum": true 77 | }, 78 | "AppliedAmount": { 79 | "type": [ 80 | "null", 81 | "number" 82 | ], 83 | "minimum": -1e+33, 84 | "maximum": 1e+33, 85 | "multipleOf": 1e-05, 86 | "exclusiveMinimum": true, 87 | "exclusiveMaximum": true 88 | }, 89 | "TotalTax": { 90 | "type": [ 91 | "null", 92 | "number" 93 | ], 94 | "minimum": -1e+33, 95 | "maximum": 1e+33, 96 | "multipleOf": 1e-05, 97 | "exclusiveMinimum": true, 98 | "exclusiveMaximum": true 99 | }, 100 | "Total": { 101 | "type": [ 102 | "null", 103 | "number" 104 | ], 105 | "minimum": -1e+33, 106 | "maximum": 1e+33, 107 | "multipleOf": 1e-05, 108 | "exclusiveMinimum": true, 109 | "exclusiveMaximum": true 110 | }, 111 | "UpdatedDateUTC": { 112 | "format": "date-time", 113 | "type": [ 114 | "null", 115 | "string" 116 | ] 117 | }, 118 | "CurrencyCode": { 119 | "type": [ 120 | "null", 121 | "string" 122 | ] 123 | }, 124 | "FullyPaidOnDate": { 125 | "format": "date-time", 126 | "type": [ 127 | "null", 128 | "string" 129 | ] 130 | }, 131 | "CreditNoteID": { 132 | "type": [ 133 | "string" 134 | ] 135 | }, 136 | "CreditNoteNumber": { 137 | "type": [ 138 | "null", 139 | "string" 140 | ] 141 | }, 142 | "Reference": { 143 | "type": [ 144 | "null", 145 | "string" 146 | ] 147 | }, 148 | "SentToContact": { 149 | "type": [ 150 | "null", 151 | "boolean" 152 | ] 153 | }, 154 | "CurrencyRate": { 155 | "type": [ 156 | "null", 157 | "number" 158 | ], 159 | "minimum": -1e+33, 160 | "maximum": 1e+33, 161 | "multipleOf": 1e-10, 162 | "exclusiveMinimum": true, 163 | "exclusiveMaximum": true 164 | }, 165 | "RemainingCredit": { 166 | "type": [ 167 | "null", 168 | "number" 169 | ], 170 | "minimum": -1e+33, 171 | "maximum": 1e+33, 172 | "multipleOf": 1e-05, 173 | "exclusiveMinimum": true, 174 | "exclusiveMaximum": true 175 | }, 176 | "Allocations": { 177 | "items": { 178 | "$ref": "allocations" 179 | }, 180 | "type": [ 181 | "null", 182 | "array" 183 | ] 184 | }, 185 | "BrandingThemeID": { 186 | "type": [ 187 | "null", 188 | "string" 189 | ] 190 | }, 191 | "HasAttachments": { 192 | "type": [ 193 | "null", 194 | "boolean" 195 | ] 196 | }, 197 | "DateString": { 198 | "format": "date-time", 199 | "type": [ 200 | "null", 201 | "string" 202 | ] 203 | }, 204 | "ID": { 205 | "type": [ 206 | "null", 207 | "string" 208 | ] 209 | } 210 | }, 211 | "tap_schema_dependencies": [ 212 | "contacts", 213 | "line_items", 214 | "invoice_address", 215 | "allocations" 216 | ], 217 | "additionalProperties": false 218 | } 219 | -------------------------------------------------------------------------------- /tap_xero/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import json 4 | import singer 5 | from singer import metadata, metrics, utils 6 | from singer.catalog import Catalog, CatalogEntry, Schema 7 | from . import streams as streams_ 8 | from .client import XeroClient 9 | from .context import Context 10 | 11 | REQUIRED_CONFIG_KEYS = [ 12 | "start_date", 13 | "client_id", 14 | "client_secret", 15 | "tenant_id", 16 | "refresh_token", 17 | 18 | ] 19 | 20 | LOGGER = singer.get_logger() 21 | 22 | BAD_CREDS_MESSAGE = ( 23 | "Failed to refresh OAuth token using the credentials from both the config and S3. " 24 | "The token might need to be reauthorized from the integration's properties " 25 | "or there could be another authentication issue. Please attempt to reauthorize " 26 | "the integration." 27 | ) 28 | 29 | 30 | class BadCredsException(Exception): 31 | pass 32 | 33 | 34 | def get_abs_path(path): 35 | return os.path.join(os.path.dirname(os.path.realpath(__file__)), path) 36 | 37 | 38 | def load_schema(tap_stream_id): 39 | path = "schemas/{}.json".format(tap_stream_id) 40 | schema = utils.load_json(get_abs_path(path)) 41 | dependencies = schema.pop("tap_schema_dependencies", []) 42 | refs = {} 43 | for sub_stream_id in dependencies: 44 | refs[sub_stream_id] = load_schema(sub_stream_id) 45 | if refs: 46 | singer.resolve_schema_references(schema, refs) 47 | return schema 48 | 49 | def load_metadata(stream, schema): 50 | mdata = metadata.new() 51 | 52 | mdata = metadata.write(mdata, (), 'table-key-properties', stream.pk_fields) 53 | mdata = metadata.write(mdata, (), 'forced-replication-method', stream.replication_method) 54 | 55 | if stream.bookmark_key: 56 | mdata = metadata.write(mdata, (), 'valid-replication-keys', [stream.bookmark_key]) 57 | 58 | for field_name in schema['properties'].keys(): 59 | if field_name in stream.pk_fields or field_name == stream.bookmark_key: 60 | mdata = metadata.write(mdata, ('properties', field_name), 'inclusion', 'automatic') 61 | else: 62 | mdata = metadata.write(mdata, ('properties', field_name), 'inclusion', 'available') 63 | 64 | return metadata.to_list(mdata) 65 | 66 | 67 | def ensure_credentials_are_valid(config): 68 | XeroClient(config).filter("currencies") 69 | 70 | def discover(ctx): 71 | ctx.check_platform_access() 72 | catalog = Catalog([]) 73 | for stream in streams_.all_streams: 74 | schema_dict = load_schema(stream.tap_stream_id) 75 | mdata = load_metadata(stream, schema_dict) 76 | 77 | schema = Schema.from_dict(schema_dict) 78 | catalog.streams.append(CatalogEntry( 79 | stream=stream.tap_stream_id, 80 | tap_stream_id=stream.tap_stream_id, 81 | key_properties=stream.pk_fields, 82 | schema=schema, 83 | metadata=mdata 84 | )) 85 | return catalog 86 | 87 | 88 | def load_and_write_schema(stream): 89 | singer.write_schema( 90 | stream.tap_stream_id, 91 | load_schema(stream.tap_stream_id), 92 | stream.pk_fields, 93 | ) 94 | 95 | 96 | def sync(ctx): 97 | ctx.refresh_credentials() 98 | currently_syncing = ctx.state.get("currently_syncing") 99 | start_idx = streams_.all_stream_ids.index(currently_syncing) \ 100 | if currently_syncing else 0 101 | stream_ids_to_sync = [cs.tap_stream_id for cs in ctx.catalog.streams 102 | if cs.is_selected()] 103 | streams = [s for s in streams_.all_streams[start_idx:] 104 | if s.tap_stream_id in stream_ids_to_sync] 105 | for stream in streams: 106 | ctx.state["currently_syncing"] = stream.tap_stream_id 107 | ctx.write_state() 108 | load_and_write_schema(stream) 109 | LOGGER.info("Syncing stream: %s", stream.tap_stream_id) 110 | stream.sync(ctx) 111 | ctx.state["currently_syncing"] = None 112 | ctx.write_state() 113 | 114 | 115 | 116 | def main_impl(): 117 | args = utils.parse_args(REQUIRED_CONFIG_KEYS) 118 | if args.discover: 119 | discover(Context(args.config, {}, {}, args.config_path)).dump() 120 | print() 121 | else: 122 | if args.catalog: 123 | catalog = args.catalog 124 | else: 125 | LOGGER.info("Running sync without provided Catalog. Discovering.") 126 | catalog = discover(Context(args.config, {}, {}, args.config_path)) 127 | 128 | sync(Context(args.config, args.state, catalog, args.config_path)) 129 | 130 | def main(): 131 | try: 132 | main_impl() 133 | except Exception as exc: 134 | LOGGER.critical(exc) 135 | raise exc 136 | 137 | 138 | if __name__ == "__main__": 139 | main() 140 | -------------------------------------------------------------------------------- /tap_xero/schemas/purchase_orders.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Contact": { 8 | "$ref": "contacts" 9 | }, 10 | "Date": { 11 | "format": "date-time", 12 | "type": [ 13 | "null", 14 | "string" 15 | ] 16 | }, 17 | "DeliveryDate": { 18 | "format": "date-time", 19 | "type": [ 20 | "null", 21 | "string" 22 | ] 23 | }, 24 | "LineAmountTypes": { 25 | "type": [ 26 | "null", 27 | "string" 28 | ] 29 | }, 30 | "PurchaseOrderNumber": { 31 | "type": [ 32 | "null", 33 | "string" 34 | ] 35 | }, 36 | "Reference": { 37 | "type": [ 38 | "null", 39 | "string" 40 | ] 41 | }, 42 | "LineItems": { 43 | "type": [ 44 | "null", 45 | "array" 46 | ], 47 | "items": { 48 | "$ref": "line_items" 49 | } 50 | }, 51 | "BrandingThemeID": { 52 | "type": [ 53 | "null", 54 | "string" 55 | ] 56 | }, 57 | "CurrencyCode": { 58 | "type": [ 59 | "null", 60 | "string" 61 | ] 62 | }, 63 | "Status": { 64 | "type": [ 65 | "null", 66 | "string" 67 | ] 68 | }, 69 | "SentToContact": { 70 | "type": [ 71 | "null", 72 | "boolean" 73 | ] 74 | }, 75 | "DeliveryAddress": { 76 | "type": [ 77 | "null", 78 | "string" 79 | ] 80 | }, 81 | "AttentionTo": { 82 | "type": [ 83 | "null", 84 | "string" 85 | ] 86 | }, 87 | "Telephone": { 88 | "type": [ 89 | "null", 90 | "string" 91 | ] 92 | }, 93 | "DeliveryInstructions": { 94 | "type": [ 95 | "null", 96 | "string" 97 | ] 98 | }, 99 | "ExpectedArrivalDate": { 100 | "format": "date-time", 101 | "type": [ 102 | "null", 103 | "string" 104 | ] 105 | }, 106 | "PurchaseOrderID": { 107 | "type": [ 108 | "string" 109 | ] 110 | }, 111 | "CurrencyRate": { 112 | "type": [ 113 | "null", 114 | "number" 115 | ], 116 | "minimum": -1e+33, 117 | "maximum": 1e+33, 118 | "multipleOf": 1e-10, 119 | "exclusiveMinimum": true, 120 | "exclusiveMaximum": true 121 | }, 122 | "SubTotal": { 123 | "type": [ 124 | "null", 125 | "number" 126 | ], 127 | "minimum": -1e+33, 128 | "maximum": 1e+33, 129 | "multipleOf": 1e-05, 130 | "exclusiveMinimum": true, 131 | "exclusiveMaximum": true 132 | }, 133 | "TotalTax": { 134 | "type": [ 135 | "null", 136 | "number" 137 | ], 138 | "minimum": -1e+33, 139 | "maximum": 1e+33, 140 | "multipleOf": 1e-05, 141 | "exclusiveMinimum": true, 142 | "exclusiveMaximum": true 143 | }, 144 | "Total": { 145 | "type": [ 146 | "null", 147 | "number" 148 | ], 149 | "minimum": -1e+33, 150 | "maximum": 1e+33, 151 | "multipleOf": 1e-05, 152 | "exclusiveMinimum": true, 153 | "exclusiveMaximum": true 154 | }, 155 | "TotalDiscount": { 156 | "type": [ 157 | "null", 158 | "number" 159 | ], 160 | "minimum": -1e+33, 161 | "maximum": 1e+33, 162 | "multipleOf": 1e-05, 163 | "exclusiveMinimum": true, 164 | "exclusiveMaximum": true 165 | }, 166 | "HasAttachments": { 167 | "type": [ 168 | "null", 169 | "boolean" 170 | ] 171 | }, 172 | "UpdatedDateUTC": { 173 | "format": "date-time", 174 | "type": [ 175 | "null", 176 | "string" 177 | ] 178 | }, 179 | "DeliveryDateString": { 180 | "format": "date-time", 181 | "type": [ 182 | "null", 183 | "string" 184 | ] 185 | }, 186 | "Type": { 187 | "type": [ 188 | "null", 189 | "string" 190 | ] 191 | }, 192 | "DateString": { 193 | "format": "date-time", 194 | "type": [ 195 | "null", 196 | "string" 197 | ] 198 | }, 199 | "HasErrors": { 200 | "type": [ 201 | "null", 202 | "boolean" 203 | ] 204 | }, 205 | "IsDiscounted": { 206 | "type": [ 207 | "null", 208 | "boolean" 209 | ] 210 | }, 211 | "ExpectedArrivalDateString": { 212 | "format": "date-time", 213 | "type": [ 214 | "null", 215 | "string" 216 | ] 217 | } 218 | }, 219 | "tap_schema_dependencies": [ 220 | "contacts", 221 | "line_items" 222 | ], 223 | "additionalProperties": false 224 | } 225 | -------------------------------------------------------------------------------- /tap_xero/schemas/nested_invoice.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Type": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "Contact": { 14 | "$ref": "contacts" 15 | }, 16 | "Date": { 17 | "format": "date-time", 18 | "type": [ 19 | "null", 20 | "string" 21 | ] 22 | }, 23 | "DueDate": { 24 | "format": "date-time", 25 | "type": [ 26 | "null", 27 | "string" 28 | ] 29 | }, 30 | "Status": { 31 | "type": [ 32 | "null", 33 | "string" 34 | ] 35 | }, 36 | "LineAmountTypes": { 37 | "type": [ 38 | "null", 39 | "string" 40 | ] 41 | }, 42 | "LineItems": { 43 | "type": [ 44 | "null", 45 | "array" 46 | ], 47 | "items": { 48 | "$ref": "line_items" 49 | } 50 | }, 51 | "SubTotal": { 52 | "type": [ 53 | "null", 54 | "number" 55 | ], 56 | "minimum": -1e+33, 57 | "maximum": 1e+33, 58 | "multipleOf": 1e-05, 59 | "exclusiveMinimum": true, 60 | "exclusiveMaximum": true 61 | }, 62 | "TotalTax": { 63 | "type": [ 64 | "null", 65 | "number" 66 | ], 67 | "minimum": -1e+33, 68 | "maximum": 1e+33, 69 | "multipleOf": 1e-05, 70 | "exclusiveMinimum": true, 71 | "exclusiveMaximum": true 72 | }, 73 | "Total": { 74 | "type": [ 75 | "null", 76 | "number" 77 | ], 78 | "minimum": -1e+33, 79 | "maximum": 1e+33, 80 | "multipleOf": 1e-05, 81 | "exclusiveMinimum": true, 82 | "exclusiveMaximum": true 83 | }, 84 | "TotalDiscount": { 85 | "type": [ 86 | "null", 87 | "number" 88 | ], 89 | "minimum": -1e+33, 90 | "maximum": 1e+33, 91 | "multipleOf": 1e-05, 92 | "exclusiveMinimum": true, 93 | "exclusiveMaximum": true 94 | }, 95 | "UpdatedDateUTC": { 96 | "format": "date-time", 97 | "type": [ 98 | "null", 99 | "string" 100 | ] 101 | }, 102 | "CurrencyCode": { 103 | "type": [ 104 | "null", 105 | "string" 106 | ] 107 | }, 108 | "CurrencyRate": { 109 | "type": [ 110 | "null", 111 | "number" 112 | ], 113 | "minimum": -1e+33, 114 | "maximum": 1e+33, 115 | "multipleOf": 1e-10, 116 | "exclusiveMinimum": true, 117 | "exclusiveMaximum": true 118 | }, 119 | "InvoiceID": { 120 | "type": [ 121 | "string" 122 | ] 123 | }, 124 | "InvoiceNumber": { 125 | "type": [ 126 | "null", 127 | "string" 128 | ] 129 | }, 130 | "Reference": { 131 | "type": [ 132 | "null", 133 | "string" 134 | ] 135 | }, 136 | "BrandingThemeID": { 137 | "type": [ 138 | "null", 139 | "string" 140 | ] 141 | }, 142 | "Url": { 143 | "type": [ 144 | "null", 145 | "string" 146 | ] 147 | }, 148 | "SentToContact": { 149 | "type": [ 150 | "null", 151 | "boolean" 152 | ] 153 | }, 154 | "ExpectedPaymentDate": { 155 | "type": [ 156 | "null", 157 | "string" 158 | ], 159 | "format": "date-time" 160 | }, 161 | "PlannedPaymentDate": { 162 | "type": [ 163 | "null", 164 | "string" 165 | ], 166 | "format": "date-time" 167 | }, 168 | "HasAttachments": { 169 | "type": [ 170 | "null", 171 | "boolean" 172 | ] 173 | }, 174 | "AmountDue": { 175 | "type": [ 176 | "null", 177 | "number" 178 | ], 179 | "minimum": -1e+33, 180 | "maximum": 1e+33, 181 | "multipleOf": 1e-05, 182 | "exclusiveMinimum": true, 183 | "exclusiveMaximum": true 184 | }, 185 | "AmountPaid": { 186 | "type": [ 187 | "null", 188 | "number" 189 | ], 190 | "minimum": -1e+33, 191 | "maximum": 1e+33, 192 | "multipleOf": 1e-05, 193 | "exclusiveMinimum": true, 194 | "exclusiveMaximum": true 195 | }, 196 | "FullyPaidOnDate": { 197 | "format": "date-time", 198 | "type": [ 199 | "null", 200 | "string" 201 | ] 202 | }, 203 | "AmountCredited": { 204 | "type": [ 205 | "null", 206 | "number" 207 | ], 208 | "minimum": -1e+33, 209 | "maximum": 1e+33, 210 | "multipleOf": 1e-05, 211 | "exclusiveMinimum": true, 212 | "exclusiveMaximum": true 213 | }, 214 | "DueDateString": { 215 | "format": "date-time", 216 | "type": [ 217 | "null", 218 | "string" 219 | ] 220 | }, 221 | "IsDiscounted": { 222 | "type": [ 223 | "null", 224 | "boolean" 225 | ] 226 | }, 227 | "HasErrors": { 228 | "type": [ 229 | "null", 230 | "boolean" 231 | ] 232 | }, 233 | "DateString": { 234 | "format": "date-time", 235 | "type": [ 236 | "null", 237 | "string" 238 | ] 239 | } 240 | }, 241 | "tap_schema_dependencies": [ 242 | "contacts", 243 | "line_items" 244 | ], 245 | "additionalProperties": false 246 | } 247 | -------------------------------------------------------------------------------- /tests/unittests/test_archived_contacts_support.py: -------------------------------------------------------------------------------- 1 | import tap_xero.streams as stream_ 2 | import unittest 3 | import requests 4 | from unittest import mock 5 | 6 | class MockConfig: 7 | def __init__(self): 8 | self.config = { 9 | "include_archived_contacts": "true" 10 | } 11 | 12 | def update_start_date_bookmark(self,bookmark): 13 | return "2021-04-01" 14 | 15 | def get_offset(self, offset): 16 | return None 17 | 18 | def set_offset(self, offset, curr_page_num): 19 | return curr_page_num 20 | 21 | def write_state(self): 22 | return "" 23 | 24 | def clear_offsets(self, tap_stream_id): 25 | return "" 26 | 27 | def set_bookmark(self, bookmark, max_updated): 28 | return "" 29 | 30 | 31 | class TestSupportArchivedContacts(unittest.TestCase): 32 | """ 33 | Test cases to verify the support of Archived contacts from the Xero API 34 | """ 35 | 36 | @mock.patch("tap_xero.streams._make_request") 37 | def test_archived_contacts_selected_string(self, mocked_make_request_method): 38 | mocked_make_request_method.return_value = [] 39 | tap_stream_id = "contacts" 40 | contacts_stream_execution = stream_.Contacts() 41 | 42 | # ArchivedContacts parameter set to true in the MockConfig class 43 | ctx = MockConfig() 44 | sync_resp = contacts_stream_execution.sync(ctx) 45 | 46 | expected_filter_options = dict(since="2021-04-01", order="UpdatedDateUTC ASC", includeArchived="true", page=1) 47 | 48 | # Verifying the parameters send to the _make_request method which is responsible for collecting data from the Xero platform 49 | mocked_make_request_method.assert_called_with(ctx, tap_stream_id, expected_filter_options) 50 | 51 | 52 | @mock.patch("tap_xero.streams._make_request") 53 | def test_archived_contacts_selected_boolean(self, mocked_make_request_method): 54 | mocked_make_request_method.return_value = [] 55 | tap_stream_id = "contacts" 56 | contacts_stream_execution = stream_.Contacts() 57 | 58 | # ArchivedContacts parameter set to true in the MockConfig class 59 | ctx = MockConfig() 60 | 61 | # Customer may also pass a boolean parameter for include_archived_contacts 62 | ctx.config["include_archived_contacts"] = True 63 | sync_resp = contacts_stream_execution.sync(ctx) 64 | 65 | expected_filter_options = dict(since="2021-04-01", order="UpdatedDateUTC ASC", includeArchived="true", page=1) 66 | 67 | # Verifying the parameters send to the _make_request method which is responsible for collecting data from the Xero platform 68 | mocked_make_request_method.assert_called_with(ctx, tap_stream_id, expected_filter_options) 69 | 70 | 71 | @mock.patch("tap_xero.streams._make_request") 72 | def test_archived_contacts_not_selected(self, mocked_make_request_method): 73 | mocked_make_request_method.return_value = [] 74 | tap_stream_id = "contacts" 75 | contacts_stream_execution = stream_.Contacts() 76 | 77 | # ArchivedContacts parameter set to true in the MockConfig class 78 | ctx = MockConfig() 79 | # Setting the archived contacts parameter value to "false" to get only active contacts result 80 | ctx.config["include_archived_contacts"] = "false" 81 | sync_resp = contacts_stream_execution.sync(ctx) 82 | 83 | expected_filter_options = dict(since="2021-04-01", order="UpdatedDateUTC ASC", page=1) 84 | 85 | # Verifying the parameters send to the _make_request method which is responsible for collecting data from the Xero platform 86 | mocked_make_request_method.assert_called_with(ctx, tap_stream_id, expected_filter_options) 87 | 88 | @mock.patch("tap_xero.streams._make_request") 89 | def test_archived_contacts_parameter_with_other_streams(self, mocked_make_request_method): 90 | mocked_make_request_method.return_value = [] 91 | tap_stream_id = "accounts" 92 | pk_fields = ["AccountID"] 93 | contacts_stream_execution = stream_.PaginatedStream(tap_stream_id, pk_fields) 94 | 95 | # ArchivedContacts parameter set to true in the MockConfig class. However, for other streams, this parameter should not be passed 96 | # while contacting Xero platform 97 | ctx = MockConfig() 98 | sync_resp = contacts_stream_execution.sync(ctx) 99 | 100 | expected_filter_options = dict(since="2021-04-01", order="UpdatedDateUTC ASC", page=1) 101 | 102 | # Verifying the parameters send to the _make_request method which is responsible for collecting data from the Xero platform 103 | mocked_make_request_method.assert_called_with(ctx, tap_stream_id, expected_filter_options) 104 | 105 | @mock.patch("tap_xero.streams._make_request") 106 | def test_archived_contacts_option_not_passed_in_config(self, mocked_make_request_method): 107 | mocked_make_request_method.return_value = [] 108 | tap_stream_id = "contacts" 109 | contacts_stream_execution = stream_.Contacts() 110 | 111 | # ArchivedContacts parameter set to true in the MockConfig class 112 | ctx = MockConfig() 113 | 114 | # Deleting the 'include_archived_contacts' option from the config dictionary 115 | del ctx.config["include_archived_contacts"] 116 | sync_resp = contacts_stream_execution.sync(ctx) 117 | 118 | expected_filter_options = dict(since="2021-04-01", order="UpdatedDateUTC ASC", page=1) 119 | 120 | # Verifying the parameters send to the _make_request method which is responsible for collecting data from the Xero platform 121 | mocked_make_request_method.assert_called_with(ctx, tap_stream_id, expected_filter_options) 122 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.3.2 4 | * Bump dependency versions for twistlock compliance [#122](https://github.com/singer-io/tap-xero/pull/122) 5 | 6 | ## 2.3.1 7 | * Add new fields into the schema of credit_notes stream [#117](https://github.com/singer-io/tap-xero/pull/117) 8 | 9 | ## 2.3.0 10 | * Updates to run on python 3.9.6 [#113](https://github.com/singer-io/tap-xero/pull/113) 11 | 12 | ## 2.2.3 13 | * Adds a workaround for a Xero bug to allow pagination to function properly in the `manual_journals` stream [#104](https://github.com/singer-io/tap-xero/pull/104) 14 | 15 | ## 2.2.2 16 | * Changes the endpoint used for check_platform_access from `Contacts` to `Currencies` [#98](https://github.com/singer-io/tap-xero/pull/98) 17 | 18 | ## 2.2.1 19 | * Make the TotalDiscount schema on the Quotes stream the same as the schema on the other streams [#93](https://github.com/singer-io/tap-xero/pull/93) 20 | 21 | ## 2.2.0 22 | * Additional handling for HTTP 429 responses from Xero [#90](https://github.com/singer-io/tap-xero/pull/90) 23 | * Adding a feature to allow the syncing of Archived Contacts via `include_archived_contacts` config [#84](https://github.com/singer-io/tap-xero/pull/84) 24 | 25 | ## 2.1.0 26 | * Add retry to JSON decode error [#83](https://github.com/singer-io/tap-xero/pull/83) 27 | * Add backoff mechanism for 429 errors. Validate authorization during discovery [#85](https://github.com/singer-io/tap-xero/pull/85) 28 | * Add Quotes stream support [#86](https://github.com/singer-io/tap-xero/pull/86) 29 | * Increase the allowable precision in the CurrencyRate schema from `1e-06` to `1e-10` [#87](https://github.com/singer-io/tap-xero/pull/87) 30 | 31 | ## 2.0.4 32 | * In json parsing hook, explicitly check for `date` type values to add 0 hh:mm:ss to so that the parser avoids truncating `datetime` objects [#79](https://github.com/singer-io/tap-xero/pull/79) 33 | 34 | ## 2.0.3 35 | * Switching the Payments stream to use the PaginatedStream class as that API endpoint can paginate using the "page" query param [#76](https://github.com/singer-io/tap-xero/pull/76) 36 | 37 | ## 2.0.2 38 | * Add handling for date formatting on negative dates [#74](https://github.com/singer-io/tap-xero/pull/74) 39 | 40 | ## 2.0.1 41 | * Check for if a value is in datetime format before parsing as a datetime [#70](https://github.com/singer-io/tap-xero/pull/70) 42 | 43 | ## 2.0.0 44 | * Switch from OAuth1 to OAuth2. This will no longer work with OAuth1 credentials and any users will need to [migrate their tokens](https://developer.xero.com/documentation/oauth2/migrate) [#66](https://github.com/singer-io/tap-xero/pull/66) 45 | 46 | ## 1.0.4 47 | * Add handling for date formatting on JournalDate for when Xero returns the date as '/Date(0+0000)/' [#64](https://github.com/singer-io/tap-xero/pull/64) 48 | 49 | ## 1.0.3 50 | * Add handling for date formatting when an invoice is voided [#62](https://github.com/singer-io/tap-xero/pull/62) 51 | 52 | ## 1.0.2 53 | * Restrict the version of pyxero being used more explicitly 54 | 55 | ## 1.0.1 56 | * Update version of `requests` to `2.20.0` in response to CVE 2018-18074 57 | 58 | ## 1.0.0 59 | * Preparing for release 60 | 61 | ## 0.2.1 62 | * Fixes a bug removing a reference to the pendulum library which is no longer used 63 | 64 | ## 0.2.0 65 | * Updates the tap to use metadata and allow for property selection [#58](https://github.com/singer-io/tap-xero/pull/58) 66 | 67 | ## 0.1.30 68 | * Adds `AccountID` to the schema for manual_journal.JournalLines [#57](https://github.com/singer-io/tap-xero/pull/57) 69 | 70 | ## 0.1.29 71 | * Adds "TotalDiscount" to repeating_invoices schema [#55](https://github.com/singer-io/tap-xero/pull/55) 72 | * Fixes [bug](https://github.com/singer-io/tap-xero/issues/53) where linked transaction page was not incrementing [#54](https://github.com/singer-io/tap-xero/pull/54) 73 | 74 | ## 0.1.28 75 | * Fixes [bug](https://github.com/singer-io/tap-xero/issues/50) where empty list was being returned during an API call after re-auth [#51](https://github.com/singer-io/tap-xero/pull/51) 76 | 77 | ## 0.1.27 78 | * Fixes issue where credentials wouldn't refresh properly during a tap run > 30 min 79 | 80 | ## 0.1.26 81 | * Add payments to tap_schema_dependencies in prepayments schema 82 | 83 | ## 0.1.25 84 | * Add Payments array to prepayments schema [#47](https://github.com/singer-io/tap-xero/pull/47) 85 | 86 | ## 0.1.24 87 | * Add "ID" and "AppliedAmount" fields to prepayments schema [#44](https://github.com/singer-io/tap-xero/pull/44/files) 88 | 89 | ## 0.1.23 90 | * Fix for refreshing credentials after short term expiration. 91 | 92 | ## 0.1.21 93 | * Fixes formatting of overpayments and prepayments [#37](https://github.com/singer-io/tap-xero/pull/37) 94 | 95 | ## 0.1.20 96 | * Fixes PaymentTerm schema issue in contacts stream [#31](https://github.com/singer-io/tap-xero/pull/31) 97 | * Fixes bug in LinkedTransactions stream where start time was not parsed properly into datetime [#28](https://github.com/singer-io/tap-xero/pull/28) 98 | 99 | ## 0.1.19 100 | * Fixes usage of 'since' parameter [#24](https://github.com/singer-io/tap-xero/pull/24) 101 | * Adds TrackingOptionName and TrackingCategoryName to tracking_categories sub-schema [#25](https://github.com/singer-io/tap-xero/pull/25) 102 | * Adds IsNonRecoverable field to tax_rates schema [#26](https://github.com/singer-io/tap-xero/pull/26) 103 | 104 | ## 0.1.18 105 | * Adding ExpectedPaymentDateString to invoice schema [#21](https://github.com/singer-io/tap-xero/pull/21) 106 | 107 | ## 0.1.17 108 | * Fix a bug in the bank_transactions schema. 109 | 110 | ## 0.1.16 111 | * Added ExternalLinkProviderName and fixed multipleOf precision for bank_transactions schema [#19](https://github.com/singer-io/tap-xero/pull/19) 112 | 113 | ## 0.1.15 114 | * Added PlannedPaymentDateString to invoices schema [#18](https://github.com/singer-io/tap-xero/pull/18) 115 | -------------------------------------------------------------------------------- /tap_xero/schemas/invoices.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "Type": { 8 | "type": [ 9 | "null", 10 | "string" 11 | ] 12 | }, 13 | "Contact": { 14 | "$ref": "contacts" 15 | }, 16 | "Date": { 17 | "format": "date-time", 18 | "type": [ 19 | "null", 20 | "string" 21 | ] 22 | }, 23 | "DueDate": { 24 | "format": "date-time", 25 | "type": [ 26 | "null", 27 | "string" 28 | ] 29 | }, 30 | "Status": { 31 | "type": [ 32 | "null", 33 | "string" 34 | ] 35 | }, 36 | "LineAmountTypes": { 37 | "type": [ 38 | "null", 39 | "string" 40 | ] 41 | }, 42 | "LineItems": { 43 | "type": [ 44 | "null", 45 | "array" 46 | ], 47 | "items": { 48 | "$ref": "line_items" 49 | } 50 | }, 51 | "SubTotal": { 52 | "type": [ 53 | "null", 54 | "number" 55 | ], 56 | "minimum": -1e+33, 57 | "maximum": 1e+33, 58 | "multipleOf": 1e-05, 59 | "exclusiveMinimum": true, 60 | "exclusiveMaximum": true 61 | }, 62 | "TotalTax": { 63 | "type": [ 64 | "null", 65 | "number" 66 | ], 67 | "minimum": -1e+33, 68 | "maximum": 1e+33, 69 | "multipleOf": 1e-05, 70 | "exclusiveMinimum": true, 71 | "exclusiveMaximum": true 72 | }, 73 | "Total": { 74 | "type": [ 75 | "null", 76 | "number" 77 | ], 78 | "minimum": -1e+33, 79 | "maximum": 1e+33, 80 | "multipleOf": 1e-05, 81 | "exclusiveMinimum": true, 82 | "exclusiveMaximum": true 83 | }, 84 | "TotalDiscount": { 85 | "type": [ 86 | "null", 87 | "number" 88 | ], 89 | "minimum": -1e+33, 90 | "maximum": 1e+33, 91 | "multipleOf": 1e-05, 92 | "exclusiveMinimum": true, 93 | "exclusiveMaximum": true 94 | }, 95 | "UpdatedDateUTC": { 96 | "format": "date-time", 97 | "type": [ 98 | "null", 99 | "string" 100 | ] 101 | }, 102 | "CurrencyCode": { 103 | "type": [ 104 | "null", 105 | "string" 106 | ] 107 | }, 108 | "CurrencyRate": { 109 | "type": [ 110 | "null", 111 | "number" 112 | ], 113 | "minimum": -1e+33, 114 | "maximum": 1e+33, 115 | "multipleOf": 1e-10, 116 | "exclusiveMinimum": true, 117 | "exclusiveMaximum": true 118 | }, 119 | "InvoiceID": { 120 | "type": [ 121 | "string" 122 | ] 123 | }, 124 | "InvoiceNumber": { 125 | "type": [ 126 | "null", 127 | "string" 128 | ] 129 | }, 130 | "Reference": { 131 | "type": [ 132 | "null", 133 | "string" 134 | ] 135 | }, 136 | "BrandingThemeID": { 137 | "type": [ 138 | "null", 139 | "string" 140 | ] 141 | }, 142 | "Url": { 143 | "type": [ 144 | "null", 145 | "string" 146 | ] 147 | }, 148 | "SentToContact": { 149 | "type": [ 150 | "null", 151 | "boolean" 152 | ] 153 | }, 154 | "ExpectedPaymentDate": { 155 | "type": [ 156 | "null", 157 | "string" 158 | ], 159 | "format": "date-time" 160 | }, 161 | "ExpectedPaymentDateString": { 162 | "format": "date-time", 163 | "type": [ 164 | "null", 165 | "string" 166 | ] 167 | }, 168 | "PlannedPaymentDate": { 169 | "type": [ 170 | "null", 171 | "string" 172 | ], 173 | "format": "date-time" 174 | }, 175 | "PlannedPaymentDateString": { 176 | "format": "date-time", 177 | "type": [ 178 | "null", 179 | "string" 180 | ] 181 | }, 182 | "HasAttachments": { 183 | "type": [ 184 | "null", 185 | "boolean" 186 | ] 187 | }, 188 | "Payments": { 189 | "type": [ 190 | "null", 191 | "array" 192 | ], 193 | "items": { 194 | "$ref": "payments" 195 | } 196 | }, 197 | "CreditNotes": { 198 | "type": [ 199 | "null", 200 | "array" 201 | ], 202 | "items": { 203 | "$ref": "credit_notes" 204 | } 205 | }, 206 | "Prepayments": { 207 | "type": [ 208 | "null", 209 | "array" 210 | ], 211 | "items": { 212 | "$ref": "prepayments" 213 | } 214 | }, 215 | "Overpayments": { 216 | "type": [ 217 | "null", 218 | "array" 219 | ], 220 | "items": { 221 | "$ref": "overpayments" 222 | } 223 | }, 224 | "AmountDue": { 225 | "type": [ 226 | "null", 227 | "number" 228 | ], 229 | "minimum": -1e+33, 230 | "maximum": 1e+33, 231 | "multipleOf": 1e-05, 232 | "exclusiveMinimum": true, 233 | "exclusiveMaximum": true 234 | }, 235 | "AmountPaid": { 236 | "type": [ 237 | "null", 238 | "number" 239 | ], 240 | "minimum": -1e+33, 241 | "maximum": 1e+33, 242 | "multipleOf": 1e-05, 243 | "exclusiveMinimum": true, 244 | "exclusiveMaximum": true 245 | }, 246 | "FullyPaidOnDate": { 247 | "format": "date-time", 248 | "type": [ 249 | "null", 250 | "string" 251 | ] 252 | }, 253 | "AmountCredited": { 254 | "type": [ 255 | "null", 256 | "number" 257 | ], 258 | "minimum": -1e+33, 259 | "maximum": 1e+33, 260 | "multipleOf": 1e-05, 261 | "exclusiveMinimum": true, 262 | "exclusiveMaximum": true 263 | }, 264 | "DueDateString": { 265 | "format": "date-time", 266 | "type": [ 267 | "null", 268 | "string" 269 | ] 270 | }, 271 | "IsDiscounted": { 272 | "type": [ 273 | "null", 274 | "boolean" 275 | ] 276 | }, 277 | "HasErrors": { 278 | "type": [ 279 | "null", 280 | "boolean" 281 | ] 282 | }, 283 | "DateString": { 284 | "format": "date-time", 285 | "type": [ 286 | "null", 287 | "string" 288 | ] 289 | } 290 | }, 291 | "tap_schema_dependencies": [ 292 | "contacts", 293 | "payments", 294 | "prepayments", 295 | "overpayments", 296 | "line_items", 297 | "credit_notes" 298 | ], 299 | "additionalProperties": false 300 | } 301 | -------------------------------------------------------------------------------- /tap_xero/schemas/contacts.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": [ 3 | "null", 4 | "object" 5 | ], 6 | "properties": { 7 | "ContactID": { 8 | "type": [ 9 | "string" 10 | ] 11 | }, 12 | "ContactNumber": { 13 | "type": [ 14 | "null", 15 | "string" 16 | ] 17 | }, 18 | "AccountNumber": { 19 | "type": [ 20 | "null", 21 | "string" 22 | ] 23 | }, 24 | "ContactStatus": { 25 | "type": [ 26 | "null", 27 | "string" 28 | ] 29 | }, 30 | "Name": { 31 | "type": [ 32 | "null", 33 | "string" 34 | ] 35 | }, 36 | "FirstName": { 37 | "type": [ 38 | "null", 39 | "string" 40 | ] 41 | }, 42 | "LastName": { 43 | "type": [ 44 | "null", 45 | "string" 46 | ] 47 | }, 48 | "EmailAddress": { 49 | "type": [ 50 | "null", 51 | "string" 52 | ] 53 | }, 54 | "SkypeUserName": { 55 | "type": [ 56 | "null", 57 | "string" 58 | ] 59 | }, 60 | "BankAccountDetails": { 61 | "type": [ 62 | "null", 63 | "string" 64 | ] 65 | }, 66 | "TaxNumber": { 67 | "type": [ 68 | "null", 69 | "string" 70 | ] 71 | }, 72 | "AccountsReceivableTaxType": { 73 | "type": [ 74 | "null", 75 | "string" 76 | ] 77 | }, 78 | "AccountsPayableTaxType": { 79 | "type": [ 80 | "null", 81 | "string" 82 | ] 83 | }, 84 | "Addresses": { 85 | "items": { 86 | "$ref": "addresses" 87 | }, 88 | "type": [ 89 | "null", 90 | "array" 91 | ] 92 | }, 93 | "Phones": { 94 | "items": { 95 | "$ref": "phones" 96 | }, 97 | "type": [ 98 | "null", 99 | "array" 100 | ] 101 | }, 102 | "IsSupplier": { 103 | "type": [ 104 | "null", 105 | "boolean" 106 | ] 107 | }, 108 | "IsCustomer": { 109 | "type": [ 110 | "null", 111 | "boolean" 112 | ] 113 | }, 114 | "DefaultCurrency": { 115 | "type": [ 116 | "null", 117 | "string" 118 | ] 119 | }, 120 | "UpdatedDateUTC": { 121 | "format": "date-time", 122 | "type": [ 123 | "null", 124 | "string" 125 | ] 126 | }, 127 | "ContactPersons": { 128 | "type": [ 129 | "null", 130 | "array" 131 | ], 132 | "items": { 133 | "type": [ 134 | "null", 135 | "object" 136 | ], 137 | "properties": { 138 | "FirstName": { 139 | "type": [ 140 | "null", 141 | "string" 142 | ] 143 | }, 144 | "LastName": { 145 | "type": [ 146 | "null", 147 | "string" 148 | ] 149 | }, 150 | "EmailAddress": { 151 | "type": [ 152 | "null", 153 | "string" 154 | ] 155 | }, 156 | "IncludeInEmails": { 157 | "type": [ 158 | "null", 159 | "boolean" 160 | ] 161 | } 162 | }, 163 | "additionalProperties": false 164 | } 165 | }, 166 | "XeroNetworkKey": { 167 | "type": [ 168 | "null", 169 | "string" 170 | ] 171 | }, 172 | "SalesDefaultAccountCode": { 173 | "type": [ 174 | "null", 175 | "string" 176 | ] 177 | }, 178 | "PurchasesDefaultAccountCode": { 179 | "type": [ 180 | "null", 181 | "string" 182 | ] 183 | }, 184 | "SalesTrackingCategories": { 185 | "type": [ 186 | "null", 187 | "array" 188 | ], 189 | "items": { 190 | "$ref": "tracking_categories" 191 | } 192 | }, 193 | "PurchasesTrackingCategories": { 194 | "type": [ 195 | "null", 196 | "array" 197 | ], 198 | "items": { 199 | "$ref": "tracking_categories" 200 | } 201 | }, 202 | "TrackingCategoryName": { 203 | "type": [ 204 | "null", 205 | "string" 206 | ] 207 | }, 208 | "TrackingCategoryOption": { 209 | "type": [ 210 | "null", 211 | "string" 212 | ] 213 | }, 214 | "PaymentTerms": { 215 | "$ref": "payment_terms" 216 | }, 217 | "ContactGroups": { 218 | "items": { 219 | "$ref": "contact_groups" 220 | }, 221 | "type": [ 222 | "null", 223 | "array" 224 | ] 225 | }, 226 | "Website": { 227 | "type": [ 228 | "null", 229 | "string" 230 | ] 231 | }, 232 | "BrandingTheme": { 233 | "$ref": "branding_themes" 234 | }, 235 | "BatchPayments": { 236 | "type": [ 237 | "null", 238 | "object" 239 | ], 240 | "properties": { 241 | "Details": { 242 | "type": [ 243 | "null", 244 | "string" 245 | ] 246 | }, 247 | "Reference": { 248 | "type": [ 249 | "null", 250 | "string" 251 | ] 252 | }, 253 | "Code": { 254 | "type": [ 255 | "null", 256 | "string" 257 | ] 258 | }, 259 | "BankAccountNumber": { 260 | "type": [ 261 | "null", 262 | "string" 263 | ] 264 | }, 265 | "BankAccountName": { 266 | "type": [ 267 | "null", 268 | "string" 269 | ] 270 | } 271 | }, 272 | "additionalProperties": false 273 | }, 274 | "Discount": { 275 | "type": [ 276 | "null", 277 | "number" 278 | ] 279 | }, 280 | "Balances": { 281 | "type": [ 282 | "null", 283 | "object" 284 | ], 285 | "properties": { 286 | "AccountsReceivable": { 287 | "type": [ 288 | "null", 289 | "object" 290 | ], 291 | "properties": { 292 | "Outstanding": { 293 | "type": [ 294 | "null", 295 | "number" 296 | ] 297 | }, 298 | "Overdue": { 299 | "type": [ 300 | "null", 301 | "number" 302 | ] 303 | } 304 | }, 305 | "additionalProperties": false 306 | }, 307 | "AccountsPayable": { 308 | "type": [ 309 | "null", 310 | "object" 311 | ], 312 | "properties": { 313 | "Outstanding": { 314 | "type": [ 315 | "null", 316 | "number" 317 | ] 318 | }, 319 | "Overdue": { 320 | "type": [ 321 | "null", 322 | "number" 323 | ] 324 | } 325 | }, 326 | "additionalProperties": false 327 | } 328 | }, 329 | "additionalProperties": false 330 | }, 331 | "HasAttachments": { 332 | "type": [ 333 | "null", 334 | "boolean" 335 | ] 336 | }, 337 | "Attachments": { 338 | "$ref": "attachments" 339 | }, 340 | "HasValidationErrors": { 341 | "type": [ 342 | "null", 343 | "boolean" 344 | ] 345 | }, 346 | "ValidationErrors": { 347 | "$ref": "validation_errors" 348 | } 349 | }, 350 | "tap_schema_dependencies": [ 351 | "addresses", 352 | "phones", 353 | "contact_groups", 354 | "branding_themes", 355 | "tracking_categories", 356 | "validation_errors", 357 | "attachments", 358 | "payment_terms" 359 | ], 360 | "additionalProperties": false 361 | } 362 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime, timezone 3 | import unittest 4 | from singer import utils 5 | 6 | import tap_tester.connections as connections 7 | import tap_tester.menagerie as menagerie 8 | 9 | 10 | def preserve_refresh_token(existing_conns, payload): 11 | if not existing_conns: 12 | return payload 13 | conn_with_creds = connections.fetch_existing_connection_with_creds(existing_conns[0]['id']) 14 | payload['properties']['refresh_token'] = conn_with_creds['credentials']['refresh_token'] 15 | return payload 16 | 17 | class XeroScenarioBase(unittest.TestCase): 18 | start_dt = datetime(2001, 1, 1, tzinfo=timezone.utc) 19 | start_date = utils.strftime(start_dt) 20 | 21 | def setUp(self): 22 | required_creds = { 23 | "client_id": 'TAP_XERO_CLIENT_ID', 24 | "client_secret": 'TAP_XERO_CLIENT_SECRET', 25 | "refresh_token": 'TAP_XERO_REFRESH_TOKEN', 26 | } 27 | required_props = { 28 | "tenant_id": 'TAP_XERO_TENANT_ID', 29 | "xero_user_id": 'TAP_XERO_USER_ID' 30 | } 31 | missing_creds = [v for v in required_creds.values() if not os.getenv(v)] 32 | missing_props = [v for v in required_props.values() if not os.getenv(v)] 33 | if missing_creds or missing_props: 34 | missing_envs = missing_creds + missing_props 35 | raise Exception("set " + ", ".join(missing_envs)) 36 | self._credentials = {k: os.getenv(v) for k, v in required_creds.items()} 37 | self.conn_id = connections.ensure_connection(self, payload_hook=preserve_refresh_token) 38 | 39 | def get_type(self): 40 | return "platform.xero" 41 | 42 | def get_credentials(self): 43 | self._credentials["client_secret"] = os.getenv('TAP_XERO_CLIENT_SECRET') 44 | self._credentials["client_id"] = os.getenv('TAP_XERO_CLIENT_ID') 45 | self._credentials["refresh_token"] = os.getenv('TAP_XERO_REFRESH_TOKEN') 46 | self._credentials["access_token"] = "access_token" 47 | return self._credentials 48 | 49 | @property 50 | def expected_pks(self): 51 | return { 52 | "bank_transactions": ["BankTransactionID"], 53 | "contacts": ["ContactID"], 54 | "credit_notes": ["CreditNoteID"], 55 | "invoices": ["InvoiceID"], 56 | "manual_journals": ["ManualJournalID"], 57 | "overpayments": ["OverpaymentID"], 58 | "prepayments": ["PrepaymentID"], 59 | "purchase_orders": ["PurchaseOrderID"], 60 | "journals": ["JournalID"], 61 | "accounts": ["AccountID"], 62 | "bank_transfers": ["BankTransferID"], 63 | "employees": ["EmployeeID"], 64 | "expense_claims": ["ExpenseClaimID"], 65 | "items": ["ItemID"], 66 | "payments": ["PaymentID"], 67 | "receipts": ["ReceiptID"], 68 | "users": ["UserID"], 69 | "branding_themes": ["BrandingThemeID"], 70 | "contact_groups": ["ContactGroupID"], 71 | "currencies": ["Code"], 72 | "organisations": ["OrganisationID"], 73 | "repeating_invoices": ["RepeatingInvoiceID"], 74 | "tax_rates": ["TaxType"], 75 | "tracking_categories": ["TrackingCategoryID"], 76 | "linked_transactions": ["LinkedTransactionID"], 77 | "quotes": ["QuoteID"], 78 | } 79 | 80 | @property 81 | def expected_streams(self): 82 | return set(self.expected_pks) 83 | 84 | @property 85 | def expected_bookmarks(self): 86 | return { 87 | "bank_transactions": ["UpdatedDateUTC"], 88 | "contacts": ["UpdatedDateUTC"], 89 | "credit_notes": ["UpdatedDateUTC"], 90 | "invoices": ["UpdatedDateUTC"], 91 | "manual_journals": ["UpdatedDateUTC"], 92 | "overpayments": ["UpdatedDateUTC"], 93 | "prepayments": ["UpdatedDateUTC"], 94 | "purchase_orders": ["UpdatedDateUTC"], 95 | "journals": ["JournalNumber"], 96 | "accounts": ["UpdatedDateUTC"], 97 | "bank_transfers": ["CreatedDateUTC"], 98 | "employees": ["UpdatedDateUTC"], 99 | "expense_claims": ["UpdatedDateUTC"], 100 | "items": ["UpdatedDateUTC"], 101 | "payments": ["UpdatedDateUTC"], 102 | "receipts": ["UpdatedDateUTC"], 103 | "users": ["UpdatedDateUTC"], 104 | "linked_transactions": ["UpdatedDateUTC"], 105 | "quotes": ["UpdatedDateUTC"] 106 | } 107 | 108 | @property 109 | def expected_offsets(self): 110 | return { 111 | "bank_transactions": {}, 112 | "contacts": {}, 113 | "credit_notes": {}, 114 | "invoices": {}, 115 | "manual_journals": {}, 116 | "overpayments": {}, 117 | "prepayments": {}, 118 | "purchase_orders": {}, 119 | "linked_transactions": {}, 120 | } 121 | 122 | def record_to_bk_value(self, stream, record): 123 | if stream == "journals": 124 | return record.get("JournalNumber") 125 | if stream == "bank_transfers": 126 | return record.get("CreatedDateUTC") 127 | return record.get("UpdatedDateUTC") 128 | 129 | def tap_name(self): 130 | return "tap-xero" 131 | 132 | def get_properties(self): 133 | return { 134 | "start_date" : self.start_dt.strftime("%Y-%m-%dT%H:%M:%SZ"), 135 | "tenant_id": os.getenv('TAP_XERO_TENANT_ID'), 136 | "xero_user_id": os.getenv('TAP_XERO_USER_ID'), 137 | } 138 | 139 | def get_bookmark_default(self, stream): 140 | if stream == "journals": 141 | return 0 142 | return self.get_properties()["start_date"] 143 | 144 | def typify_bookmark(self, stream, bookmark_name, bookmark_val): 145 | try: 146 | if stream == "journals": 147 | return int(bookmark_val) 148 | return utils.strptime_with_tz(bookmark_val) 149 | except Exception as e: 150 | raise Exception("Couldn't cast (stream:{}) (bk_name:{}) (bk_val:{})" 151 | .format(stream, bookmark_name, bookmark_val)) from e 152 | 153 | def check_all_streams_in_catalogs(self, found_catalogs): 154 | found_catalog_names = {c["tap_stream_id"] for c in found_catalogs} 155 | diff = self.expected_streams.symmetric_difference(found_catalog_names) 156 | self.assertEqual( 157 | len(diff), 0, 158 | msg="discovered schemas do not match: {}".format(diff) 159 | ) 160 | print("discovered schemas are kosher") 161 | 162 | def select_found_catalogs(self, found_catalogs): 163 | # selected = [menagerie.select_catalog(self.conn_id, c) for c in found_catalogs] 164 | # menagerie.post_annotated_catalogs(self.conn_id, selected) 165 | for catalog in found_catalogs: 166 | schema = menagerie.get_annotated_schema(self.conn_id, catalog['stream_id']) 167 | non_selected_properties = [] 168 | additional_md = [] 169 | 170 | connections.select_catalog_and_fields_via_metadata( 171 | self.conn_id, catalog, schema, additional_md=additional_md, 172 | non_selected_fields=non_selected_properties 173 | ) 174 | 175 | 176 | def select_specific_catalog(self, found_catalogs, catalog_to_select): 177 | for catalog in found_catalogs: 178 | if catalog['tap_stream_id'] != catalog_to_select: 179 | continue 180 | 181 | schema = menagerie.get_annotated_schema(self.conn_id, catalog['stream_id']) 182 | non_selected_properties = [] 183 | additional_md = [] 184 | 185 | connections.select_catalog_and_fields_via_metadata( 186 | self.conn_id, catalog, schema, additional_md=additional_md, 187 | non_selected_fields=non_selected_properties 188 | ) 189 | break 190 | 191 | 192 | def look_for_unexpected_bookmarks(self, bookmarks): 193 | diff = set(bookmarks).difference(self.expected_bookmarks) 194 | self.assertEqual( 195 | len(diff), 0, 196 | msg=("Unexpected bookmarks: {} Expected: {} Actual: {}" 197 | .format(diff, self.expected_bookmarks, bookmarks)) 198 | ) 199 | -------------------------------------------------------------------------------- /tap_xero/streams.py: -------------------------------------------------------------------------------- 1 | from requests.exceptions import HTTPError 2 | import singer 3 | from singer import metadata, metrics, Transformer 4 | from singer.utils import strptime_with_tz 5 | import backoff 6 | from . import transform 7 | 8 | LOGGER = singer.get_logger() 9 | FULL_PAGE_SIZE = 100 10 | 11 | 12 | def _request_with_timer(tap_stream_id, xero, filter_options): 13 | with metrics.http_request_timer(tap_stream_id) as timer: 14 | try: 15 | resp = xero.filter(tap_stream_id, **filter_options) 16 | timer.tags[metrics.Tag.http_status_code] = 200 17 | return resp 18 | except HTTPError as e: 19 | timer.tags[metrics.Tag.http_status_code] = e.response.status_code 20 | raise 21 | 22 | 23 | class RateLimitException(Exception): 24 | pass 25 | 26 | 27 | @backoff.on_exception(backoff.expo, 28 | RateLimitException, 29 | max_tries=10, 30 | factor=2) 31 | def _make_request(ctx, tap_stream_id, filter_options=None, attempts=0): 32 | filter_options = filter_options or {} 33 | try: 34 | return _request_with_timer(tap_stream_id, ctx.client, filter_options) 35 | except HTTPError as e: 36 | if e.response.status_code == 401: 37 | if attempts == 1: 38 | raise Exception("Received Not Authorized response after credential refresh.") from e 39 | ctx.refresh_credentials() 40 | return _make_request(ctx, tap_stream_id, filter_options, attempts + 1) 41 | 42 | if e.response.status_code == 503: 43 | raise RateLimitException() from e 44 | 45 | raise 46 | assert False 47 | 48 | 49 | class Stream(): 50 | def __init__(self, tap_stream_id, pk_fields, bookmark_key="UpdatedDateUTC", format_fn=None): 51 | self.tap_stream_id = tap_stream_id 52 | self.pk_fields = pk_fields 53 | self.format_fn = format_fn or (lambda x: x) 54 | self.bookmark_key = bookmark_key 55 | self.replication_method = "INCREMENTAL" 56 | self.filter_options = {} 57 | 58 | def metrics(self, records): 59 | with metrics.record_counter(self.tap_stream_id) as counter: 60 | counter.increment(len(records)) 61 | 62 | def write_records(self, records, ctx): 63 | stream = ctx.catalog.get_stream(self.tap_stream_id) 64 | schema = stream.schema.to_dict() 65 | mdata = stream.metadata 66 | for rec in records: 67 | with Transformer() as transformer: 68 | rec = transformer.transform(rec, schema, metadata.to_map(mdata)) 69 | singer.write_record(self.tap_stream_id, rec) 70 | self.metrics(records) 71 | 72 | 73 | class BookmarkedStream(Stream): 74 | def sync(self, ctx): 75 | bookmark = [self.tap_stream_id, self.bookmark_key] 76 | start = ctx.update_start_date_bookmark(bookmark) 77 | records = _make_request(ctx, self.tap_stream_id, dict(since=start)) 78 | if records: 79 | self.format_fn(records) 80 | self.write_records(records, ctx) 81 | max_bookmark_value = max([record[self.bookmark_key] for record in records]) # pylint: disable=consider-using-generator 82 | ctx.set_bookmark(bookmark, max_bookmark_value) 83 | ctx.write_state() 84 | 85 | 86 | class PaginatedStream(Stream): 87 | def __init__(self, *args, **kwargs): 88 | super().__init__(*args, **kwargs) 89 | 90 | def sync(self, ctx): 91 | bookmark = [self.tap_stream_id, self.bookmark_key] 92 | offset = [self.tap_stream_id, "page"] 93 | start = ctx.update_start_date_bookmark(bookmark) 94 | curr_page_num = ctx.get_offset(offset) or 1 95 | 96 | self.filter_options.update({"since": start}) 97 | 98 | # Xero bug causes all manual_journal records to be returned instead of 99 | # 100 per page when `order` is specified. `UpdatedDateUTC ASC` is the 100 | # default so we can safely exclude it until the bug is fixed. 101 | if self.tap_stream_id != "manual_journals": 102 | self.filter_options.update({"order": "UpdatedDateUTC ASC"}) 103 | 104 | max_updated = start 105 | while True: 106 | ctx.set_offset(offset, curr_page_num) 107 | ctx.write_state() 108 | self.filter_options["page"] = curr_page_num 109 | records = _make_request(ctx, self.tap_stream_id, self.filter_options) 110 | if records: 111 | self.format_fn(records) 112 | self.write_records(records, ctx) 113 | max_updated = records[-1][self.bookmark_key] 114 | if not records or len(records) < FULL_PAGE_SIZE: 115 | break 116 | curr_page_num += 1 117 | ctx.clear_offsets(self.tap_stream_id) 118 | ctx.set_bookmark(bookmark, max_updated) 119 | ctx.write_state() 120 | 121 | 122 | class Contacts(PaginatedStream): 123 | def __init__(self, *args, **kwargs): 124 | super().__init__("contacts", ["ContactID"], format_fn=transform.format_contacts, *args, **kwargs) 125 | 126 | def sync(self, ctx): 127 | # Parameter to collect archived contacts from the Xero platform 128 | if ctx.config.get("include_archived_contacts") in ["true", True]: 129 | self.filter_options.update({'includeArchived': "true"}) 130 | 131 | super().sync(ctx) 132 | 133 | 134 | class Journals(Stream): 135 | """The Journals endpoint is a special case. It has its own way of ordering 136 | and paging the data. See 137 | https://developer.xero.com/documentation/api/journals""" 138 | def sync(self, ctx): 139 | bookmark = [self.tap_stream_id, self.bookmark_key] 140 | journal_number = ctx.get_bookmark(bookmark) or 0 141 | while True: 142 | filter_options = {"offset": journal_number} 143 | records = _make_request(ctx, self.tap_stream_id, filter_options) 144 | if records: 145 | self.format_fn(records) 146 | self.write_records(records, ctx) 147 | journal_number = max((record[self.bookmark_key] for record in records)) 148 | ctx.set_bookmark(bookmark, journal_number) 149 | ctx.write_state() 150 | if not records or len(records) < FULL_PAGE_SIZE: 151 | break 152 | 153 | 154 | class LinkedTransactions(Stream): 155 | """The Linked Transactions endpoint is a special case. It supports 156 | pagination, but not the Modified At header, but the objects returned have 157 | the UpdatedDateUTC timestamp in them. Therefore we must always iterate over 158 | all of the data, but we can manually omit records based on the 159 | UpdatedDateUTC property.""" 160 | def sync(self, ctx): 161 | bookmark = [self.tap_stream_id, self.bookmark_key] 162 | offset = [self.tap_stream_id, "page"] 163 | start = ctx.update_start_date_bookmark(bookmark) 164 | curr_page_num = ctx.get_offset(offset) or 1 165 | max_updated = start 166 | while True: 167 | ctx.set_offset(offset, curr_page_num) 168 | ctx.write_state() 169 | filter_options = {"page": curr_page_num} 170 | raw_records = _make_request(ctx, self.tap_stream_id, filter_options) 171 | records = [x for x in raw_records 172 | if strptime_with_tz(x[self.bookmark_key]) >= strptime_with_tz(start)] 173 | if records: 174 | self.write_records(records, ctx) 175 | max_updated = records[-1][self.bookmark_key] 176 | if not records or len(records) < FULL_PAGE_SIZE: 177 | break 178 | curr_page_num += 1 179 | ctx.clear_offsets(self.tap_stream_id) 180 | ctx.set_bookmark(bookmark, max_updated) 181 | ctx.write_state() 182 | 183 | 184 | class Everything(Stream): 185 | def __init__(self, *args, **kwargs): 186 | super().__init__(*args, **kwargs) 187 | self.bookmark_key = None 188 | self.replication_method = "FULL_TABLE" 189 | 190 | def sync(self, ctx): 191 | records = _make_request(ctx, self.tap_stream_id) 192 | self.format_fn(records) 193 | self.write_records(records, ctx) 194 | 195 | 196 | all_streams = [ 197 | # PAGINATED STREAMS 198 | # These endpoints have all the best properties: they return the 199 | # UpdatedDateUTC property and support the Modified After, order, and page 200 | # parameters 201 | PaginatedStream("bank_transactions", ["BankTransactionID"]), 202 | Contacts(), 203 | PaginatedStream("quotes", ["QuoteID"]), 204 | PaginatedStream("credit_notes", ["CreditNoteID"], format_fn=transform.format_credit_notes), 205 | PaginatedStream("invoices", ["InvoiceID"], format_fn=transform.format_invoices), 206 | PaginatedStream("manual_journals", ["ManualJournalID"]), 207 | PaginatedStream("overpayments", ["OverpaymentID"], format_fn=transform.format_over_pre_payments), 208 | PaginatedStream("payments", ["PaymentID"], format_fn=transform.format_payments), 209 | PaginatedStream("prepayments", ["PrepaymentID"], format_fn=transform.format_over_pre_payments), 210 | PaginatedStream("purchase_orders", ["PurchaseOrderID"]), 211 | 212 | # JOURNALS STREAM 213 | # This endpoint is paginated, but in its own special snowflake way. 214 | Journals("journals", ["JournalID"], bookmark_key="JournalNumber", format_fn=transform.format_journals), 215 | 216 | # NON-PAGINATED STREAMS 217 | # These endpoints do not support pagination, but do support the Modified At 218 | # header. 219 | BookmarkedStream("accounts", ["AccountID"]), 220 | BookmarkedStream("bank_transfers", ["BankTransferID"], bookmark_key="CreatedDateUTC"), 221 | BookmarkedStream("employees", ["EmployeeID"]), 222 | BookmarkedStream("expense_claims", ["ExpenseClaimID"]), 223 | BookmarkedStream("items", ["ItemID"]), 224 | BookmarkedStream("receipts", ["ReceiptID"], format_fn=transform.format_receipts), 225 | BookmarkedStream("users", ["UserID"], format_fn=transform.format_users), 226 | 227 | # PULL EVERYTHING STREAMS 228 | # These endpoints do not support the Modified After header (or paging), so 229 | # we must pull all the data each time. 230 | Everything("branding_themes", ["BrandingThemeID"]), 231 | Everything("contact_groups", ["ContactGroupID"], format_fn=transform.format_contact_groups), 232 | Everything("currencies", ["Code"]), 233 | Everything("organisations", ["OrganisationID"]), 234 | Everything("repeating_invoices", ["RepeatingInvoiceID"]), 235 | Everything("tax_rates", ["TaxType"]), 236 | Everything("tracking_categories", ["TrackingCategoryID"]), 237 | 238 | # LINKED TRANSACTIONS STREAM 239 | # This endpoint is not paginated, but can do some manual filtering 240 | LinkedTransactions("linked_transactions", ["LinkedTransactionID"], bookmark_key="UpdatedDateUTC"), 241 | ] 242 | all_stream_ids = [s.tap_stream_id for s in all_streams] 243 | -------------------------------------------------------------------------------- /tap_xero/client.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | import re 3 | import json 4 | import decimal 5 | import sys 6 | import math 7 | from os.path import join 8 | from datetime import datetime, date, time, timedelta 9 | import requests 10 | from singer.utils import strftime, strptime_to_utc 11 | import six 12 | import pytz 13 | import backoff 14 | import singer 15 | 16 | LOGGER = singer.get_logger() 17 | 18 | BASE_URL = "https://api.xero.com/api.xro/2.0" 19 | 20 | 21 | class XeroError(Exception): 22 | def __init__(self, message=None, response=None): 23 | super().__init__(message) 24 | self.message = message 25 | self.response = response 26 | 27 | 28 | class XeroBadRequestError(XeroError): 29 | pass 30 | 31 | 32 | class XeroUnauthorizedError(XeroError): 33 | pass 34 | 35 | 36 | class XeroForbiddenError(XeroError): 37 | pass 38 | 39 | 40 | class XeroNotFoundError(XeroError): 41 | pass 42 | 43 | 44 | class XeroPreConditionFailedError(XeroError): 45 | pass 46 | 47 | 48 | class XeroTooManyError(XeroError): 49 | pass 50 | 51 | 52 | class XeroTooManyInMinuteError(XeroError): 53 | pass 54 | 55 | 56 | class XeroInternalError(XeroError): 57 | pass 58 | 59 | 60 | class XeroNotImplementedError(XeroError): 61 | pass 62 | 63 | 64 | class XeroNotAvailableError(XeroError): 65 | pass 66 | 67 | 68 | ERROR_CODE_EXCEPTION_MAPPING = { 69 | 400: { 70 | "raise_exception": XeroBadRequestError, 71 | "message": "A validation exception has occurred." 72 | }, 73 | 401: { 74 | "raise_exception": XeroUnauthorizedError, 75 | "message": "Invalid authorization credentials." 76 | }, 77 | 403: { 78 | "raise_exception": XeroForbiddenError, 79 | "message": "User doesn't have permission to access the resource." 80 | }, 81 | 404: { 82 | "raise_exception": XeroNotFoundError, 83 | "message": "The resource you have specified cannot be found." 84 | }, 85 | 412: { 86 | "raise_exception": XeroPreConditionFailedError, 87 | "message": "One or more conditions given in the request header fields were invalid." 88 | }, 89 | 429: { 90 | "raise_exception": XeroTooManyError, 91 | "message": "The API rate limit for your organisation/application pairing has been exceeded" 92 | }, 93 | 500: { 94 | "raise_exception": XeroInternalError, 95 | "message": "An unhandled error with the Xero API. Contact the Xero API team if problems persist." 96 | }, 97 | 501: { 98 | "raise_exception": XeroNotImplementedError, 99 | "message": "The method you have called has not been implemented." 100 | }, 101 | 503: { 102 | "raise_exception": XeroNotAvailableError, 103 | "message": "API service is currently unavailable." 104 | } 105 | } 106 | 107 | 108 | def parse_date(value): 109 | # Xero datetimes can be .NET JSON date strings which look like 110 | # "/Date(1419937200000+0000)/" 111 | # https://developer.xero.com/documentation/api/requests-and-responses 112 | pattern = r'Date\((\-?\d+)([-+])?(\d+)?\)' 113 | match = re.search(pattern, value) 114 | 115 | iso8601pattern = r'((\d{4})-([0-2]\d)-0?([0-3]\d)T([0-5]\d):([0-5]\d):([0-6]\d))' 116 | 117 | if not match: 118 | iso8601match = re.search(iso8601pattern, value) 119 | if iso8601match: 120 | try: 121 | return strptime_to_utc(value) 122 | except Exception: 123 | return None 124 | else: 125 | return None 126 | 127 | millis_timestamp, offset_sign, offset = match.groups() 128 | if offset: 129 | if offset_sign == '+': 130 | offset_sign = 1 131 | else: 132 | offset_sign = -1 133 | offset_hours = offset_sign * int(offset[:2]) 134 | offset_minutes = offset_sign * int(offset[2:]) 135 | else: 136 | offset_hours = 0 137 | offset_minutes = 0 138 | 139 | return datetime.utcfromtimestamp((int(millis_timestamp) / 1000)) \ 140 | + timedelta(hours=offset_hours, minutes=offset_minutes) 141 | 142 | 143 | def _json_load_object_hook(_dict): 144 | """Hook for json.parse(...) to parse Xero date formats.""" 145 | # This was taken from the pyxero library and modified 146 | # to format the dates according to RFC3339 147 | for key, value in _dict.items(): 148 | if isinstance(value, six.string_types): 149 | value = parse_date(value) 150 | if value: 151 | # NB> Pylint disabled because, regardless of idioms, this is more explicit than isinstance. 152 | if type(value) is date: # pylint: disable=unidiomatic-typecheck 153 | value = datetime.combine(value, time.min) 154 | value = value.replace(tzinfo=pytz.UTC) 155 | _dict[key] = strftime(value) 156 | return _dict 157 | 158 | def update_config_file(config, config_path): 159 | with open(config_path, 'w') as config_file: 160 | json.dump(config, config_file, indent=2) 161 | 162 | def is_not_status_code_fn(status_code): 163 | def gen_fn(exc): 164 | if getattr(exc, 'response', None) and getattr(exc.response, 'status_code', None) and exc.response.status_code not in status_code: 165 | return True 166 | # Retry other errors up to the max 167 | return False 168 | return gen_fn 169 | 170 | def retry_after_wait_gen(): 171 | while True: 172 | # This is called in an except block so we can retrieve the exception 173 | # and check it. 174 | exc_info = sys.exc_info() 175 | resp = exc_info[1].response 176 | sleep_time_str = resp.headers.get('Retry-After') 177 | LOGGER.info("API rate limit exceeded -- sleeping for %s seconds", sleep_time_str) 178 | yield math.floor(float(sleep_time_str)) 179 | 180 | class XeroClient(): 181 | def __init__(self, config): 182 | self.session = requests.Session() 183 | self.user_agent = config.get("user_agent") 184 | self.tenant_id = None 185 | self.access_token = None 186 | 187 | def refresh_credentials(self, config, config_path): 188 | 189 | header_token = b64encode((config["client_id"] + ":" + config["client_secret"]).encode('utf-8')) 190 | 191 | headers = { 192 | "Authorization": "Basic " + header_token.decode('utf-8'), 193 | "Content-Type": "application/x-www-form-urlencoded" 194 | } 195 | 196 | post_body = { 197 | "grant_type": "refresh_token", 198 | "refresh_token": config["refresh_token"], 199 | } 200 | resp = self.session.post("https://identity.xero.com/connect/token", headers=headers, data=post_body) 201 | 202 | if resp.status_code != 200: 203 | raise_for_error(resp) 204 | else: 205 | resp = resp.json() 206 | 207 | # Write to config file 208 | config['refresh_token'] = resp["refresh_token"] 209 | update_config_file(config, config_path) 210 | self.access_token = resp["access_token"] 211 | self.tenant_id = config['tenant_id'] 212 | 213 | 214 | @backoff.on_exception(backoff.expo, (json.decoder.JSONDecodeError, XeroInternalError), max_tries=3) 215 | @backoff.on_exception(retry_after_wait_gen, XeroTooManyInMinuteError, giveup=is_not_status_code_fn([429]), jitter=None, max_tries=3) 216 | def check_platform_access(self, config, config_path): 217 | 218 | # Validating the authentication of the provided configuration 219 | self.refresh_credentials(config, config_path) 220 | 221 | headers = { 222 | "Authorization": "Bearer " + self.access_token, 223 | "Xero-Tenant-Id": self.tenant_id, 224 | "Content-Type": "application/json" 225 | } 226 | 227 | # Validating the authorization of the provided configuration 228 | currencies_url = join(BASE_URL, "Currencies") 229 | request = requests.Request("GET", currencies_url, headers=headers) 230 | response = self.session.send(request.prepare()) 231 | 232 | if response.status_code != 200: 233 | raise_for_error(response) 234 | 235 | 236 | @backoff.on_exception(backoff.expo, (json.decoder.JSONDecodeError, XeroInternalError), max_tries=3) 237 | @backoff.on_exception(retry_after_wait_gen, XeroTooManyInMinuteError, giveup=is_not_status_code_fn([429]), jitter=None, max_tries=3) 238 | def filter(self, tap_stream_id, since=None, **params): 239 | xero_resource_name = tap_stream_id.title().replace("_", "") 240 | url = join(BASE_URL, xero_resource_name) 241 | headers = {"Accept": "application/json", 242 | "Authorization": "Bearer " + self.access_token, 243 | "Xero-tenant-id": self.tenant_id} 244 | if self.user_agent: 245 | headers["User-Agent"] = self.user_agent 246 | if since: 247 | headers["If-Modified-Since"] = since 248 | 249 | request = requests.Request("GET", url, headers=headers, params=params) 250 | response = self.session.send(request.prepare()) 251 | 252 | if response.status_code != 200: 253 | raise_for_error(response) 254 | return None 255 | else: 256 | response_meta = json.loads(response.text, 257 | object_hook=_json_load_object_hook, 258 | parse_float=decimal.Decimal) 259 | response_body = response_meta.pop(xero_resource_name) 260 | return response_body 261 | 262 | 263 | def raise_for_error(resp): 264 | try: 265 | resp.raise_for_status() 266 | except (requests.HTTPError, requests.ConnectionError) as error: 267 | try: 268 | error_code = resp.status_code 269 | 270 | # Handling status code 429 specially since the required information is present in the headers 271 | if error_code == 429: 272 | resp_headers = resp.headers 273 | api_rate_limit_message = ERROR_CODE_EXCEPTION_MAPPING[429]["message"] 274 | message = "HTTP-error-code: 429, Error: {}. Please retry after {} seconds".format(api_rate_limit_message, resp_headers.get("Retry-After")) 275 | 276 | #Raise XeroTooManyInMinuteError exception if minute limit is reached 277 | if resp_headers.get("X-Rate-Limit-Problem") == 'minute': 278 | raise XeroTooManyInMinuteError(message, resp) from None 279 | # Handling status code 403 specially since response of API does not contain enough information 280 | elif error_code in (403, 401): 281 | api_message = ERROR_CODE_EXCEPTION_MAPPING[error_code]["message"] 282 | message = "HTTP-error-code: {}, Error: {}".format(error_code, api_message) 283 | else: 284 | # Forming a response message for raising custom exception 285 | try: 286 | response_json = resp.json() 287 | except Exception: 288 | response_json = {} 289 | 290 | message = "HTTP-error-code: {}, Error: {}".format( 291 | error_code, 292 | response_json.get( 293 | "error", response_json.get( 294 | "Title", response_json.get( 295 | "Detail", ERROR_CODE_EXCEPTION_MAPPING.get( 296 | error_code, {}).get("message", "Unknown Error") 297 | )))) 298 | 299 | exc = ERROR_CODE_EXCEPTION_MAPPING.get(error_code, {}).get("raise_exception", XeroError) 300 | raise exc(message, resp) from None 301 | 302 | except (ValueError, TypeError): 303 | raise XeroError(error) from None 304 | -------------------------------------------------------------------------------- /tests/unittests/test_exception_handling.py: -------------------------------------------------------------------------------- 1 | import tap_xero.client as client_ 2 | import unittest 3 | import requests 4 | from unittest import mock 5 | import decimal 6 | import json 7 | 8 | 9 | def mocked_session(*args, **kwargs): 10 | class Mocksession: 11 | def __init__(self, json_data, status_code, content, headers, raise_error): 12 | self.text = json_data 13 | self.status_code = status_code 14 | self.raise_error = raise_error 15 | if headers: 16 | self.headers = headers 17 | 18 | def raise_for_status(self): 19 | if not self.raise_error: 20 | return self.status_code 21 | 22 | raise requests.HTTPError("sample message") 23 | 24 | def json(self): 25 | return self.text 26 | 27 | arguments_to_session = args[0] 28 | 29 | json_data = arguments_to_session[0] 30 | status_code = arguments_to_session[1] 31 | content = arguments_to_session[2] 32 | headers = arguments_to_session[3] 33 | raise_error = arguments_to_session[4] 34 | return Mocksession(json_data, status_code, content, headers, raise_error) 35 | 36 | 37 | class Mockresponse: 38 | def __init__(self, resp, status_code, content=[], headers=None, raise_error=False): 39 | self.json_data = resp 40 | self.status_code = status_code 41 | self.content = content 42 | self.headers = headers 43 | self.raise_error = raise_error 44 | 45 | def prepare(self): 46 | return (self.json_data, self.status_code, self.content, self.headers, self.raise_error) 47 | 48 | def raise_for_status(self): 49 | if not self.raise_error: 50 | return self.status_code 51 | 52 | raise requests.HTTPError("sample message") 53 | 54 | 55 | def mocked_forbidden_403_exception(*args, **kwargs): 56 | json_decode_str = {"Title": "Forbidden", "Detail": "AuthenticationUnsuccessful"} 57 | 58 | return Mockresponse(json_decode_str, 403, raise_error=True) 59 | 60 | 61 | def mocked_badrequest_400_error(*args, **kwargs): 62 | json_decode_str = {"Message": "Bad Request Error"} 63 | 64 | return Mockresponse(json_decode_str, 400, raise_error=True) 65 | 66 | 67 | def mocked_unauthorized_401_error(*args, **kwargs): 68 | json_decode_str = {"Title": "Unauthorized", "Detail": "AuthenticationUnsuccessful"} 69 | 70 | return Mockresponse(json_decode_str, 401, raise_error=True) 71 | 72 | 73 | def mocked_notfound_404_error(*args, **kwargs): 74 | json_decode_str = {} 75 | 76 | return Mockresponse(json_decode_str, 404, raise_error=True) 77 | 78 | 79 | def mocked_precondition_failed_412_error(*args, **kwargs): 80 | json_decode_str = {} 81 | 82 | return Mockresponse(json_decode_str, 412, raise_error=True) 83 | 84 | 85 | def mocked_failed_429_request_in_day(*args, **kwargs): 86 | json_decode_str = '' 87 | headers = {"Retry-After": 1000, "X-Rate-Limit-Problem": "day"} 88 | return Mockresponse(json_decode_str, 429, headers=headers, raise_error=True) 89 | 90 | 91 | def mocked_failed_429_request_in_minute(*args, **kwargs): 92 | json_decode_str = '' 93 | headers = {"Retry-After": 5, "X-Rate-Limit-Problem": "minute"} 94 | return Mockresponse(json_decode_str, 429, headers=headers, raise_error=True) 95 | 96 | 97 | def mocked_internalservererror_500_error(*args, **kwargs): 98 | json_decode_str = {} 99 | 100 | return Mockresponse(json_decode_str, 500, raise_error=True) 101 | 102 | 103 | def mocked_notimplemented_501_error(*args, **kwargs): 104 | json_decode_str = {} 105 | 106 | return Mockresponse(json_decode_str, 501, raise_error=True) 107 | 108 | 109 | def mocked_not_available_503_error(*args, **kwargs): 110 | json_decode_str = {} 111 | 112 | return Mockresponse(json_decode_str, 503, raise_error=True) 113 | 114 | 115 | def mock_successful_request(*args, **kwargs): 116 | json_decode_str = {} 117 | 118 | return Mockresponse(json_decode_str, 200) 119 | 120 | 121 | def mock_successful_session_post(*args, **kwargs): 122 | json_decode_str = {"access_token": "123", "refresh_token": "345"} 123 | 124 | return mocked_session((json_decode_str, 200, [], None, False)) 125 | 126 | 127 | def mocked_jsondecode_failing_request(*args, **kwargs): 128 | # Invalid json string 129 | json_decode_error_str = '{\'Contacts\': \'value\'}' 130 | return Mockresponse(json_decode_error_str, 200) 131 | 132 | 133 | def mocked_jsondecode_successful_request(*args, **kwargs): 134 | # Valid json string 135 | json_decode_str = '{"Contacts": "value"}' 136 | return Mockresponse(json_decode_str, 200) 137 | 138 | 139 | @mock.patch('requests.Session.send', side_effect=mocked_session) 140 | class TestFilterFunExceptionHandling(unittest.TestCase): 141 | """ 142 | Test cases to verify if the exceptions are handled as expected while communicating with Xero Environment 143 | """ 144 | 145 | @mock.patch('requests.Request', side_effect=mocked_jsondecode_failing_request) 146 | def test_json_decode_exception(self, mocked_session, mocked_jsondecode_failing_request): 147 | config = {} 148 | tap_stream_id = "contacts" 149 | 150 | xero_client = client_.XeroClient(config) 151 | xero_client.access_token = "123" 152 | xero_client.tenant_id = "123" 153 | try: 154 | filter_func_exec = xero_client.filter(tap_stream_id) 155 | except json.decoder.JSONDecodeError as e: 156 | pass 157 | 158 | self.assertEqual(mocked_jsondecode_failing_request.call_count, 3) 159 | self.assertEqual(mocked_session.call_count, 3) 160 | 161 | 162 | @mock.patch('requests.Request', side_effect=mocked_jsondecode_successful_request) 163 | def test_normal_filter_execution(self, mocked_session, mocked_jsondecode_successful_request): 164 | config = {} 165 | tap_stream_id = "contacts" 166 | 167 | xero_client = client_.XeroClient(config) 168 | xero_client.access_token = "123" 169 | xero_client.tenant_id = "123" 170 | try: 171 | filter_func_exec = xero_client.filter(tap_stream_id) 172 | except json.decoder.JSONDecodeError as e: 173 | pass 174 | 175 | self.assertEqual(mocked_jsondecode_successful_request.call_count, 1) 176 | self.assertEqual(mocked_session.call_count, 1) 177 | 178 | 179 | @mock.patch('requests.Request', side_effect=mocked_badrequest_400_error) 180 | def test_badrequest_400_error(self, mocked_session, mocked_badrequest_400_error): 181 | config = {} 182 | tap_stream_id = "contacts" 183 | 184 | xero_client = client_.XeroClient(config) 185 | xero_client.access_token = "123" 186 | xero_client.tenant_id = "123" 187 | 188 | try: 189 | xero_client.filter(tap_stream_id) 190 | except client_.XeroBadRequestError as e: 191 | expected_error_message = "HTTP-error-code: 400, Error: A validation exception has occurred." 192 | 193 | # Verifying the message formed for the custom exception 194 | self.assertEquals(str(e), expected_error_message) 195 | pass 196 | 197 | 198 | @mock.patch('requests.Request', side_effect=mocked_unauthorized_401_error) 199 | def test_unauthorized_401_error(self, mocked_session, mocked_unauthorized_401_error): 200 | config = {} 201 | tap_stream_id = "contacts" 202 | 203 | xero_client = client_.XeroClient(config) 204 | xero_client.access_token = "123" 205 | xero_client.tenant_id = "123" 206 | 207 | try: 208 | xero_client.filter(tap_stream_id) 209 | except client_.XeroUnauthorizedError as e: 210 | expected_error_message = "HTTP-error-code: 401, Error: Invalid authorization credentials." 211 | 212 | # Verifying the message formed for the custom exception 213 | self.assertEquals(str(e), expected_error_message) 214 | pass 215 | 216 | 217 | @mock.patch('requests.Request', side_effect=mocked_forbidden_403_exception) 218 | def test_forbidden_403_exception(self, mocked_session, mocked_forbidden_403_exception): 219 | config = {} 220 | tap_stream_id = "contacts" 221 | 222 | xero_client = client_.XeroClient(config) 223 | xero_client.access_token = "123" 224 | xero_client.tenant_id = "123" 225 | 226 | try: 227 | xero_client.filter(tap_stream_id) 228 | except client_.XeroForbiddenError as e: 229 | expected_error_message = "HTTP-error-code: 403, Error: User doesn't have permission to access the resource." 230 | 231 | # Verifying the message formed for the custom exception 232 | self.assertEquals(str(e), expected_error_message) 233 | pass 234 | 235 | 236 | @mock.patch('requests.Request', side_effect=mocked_notfound_404_error) 237 | def test_notfound_404_error(self, mocked_session, mocked_notfound_404_error): 238 | config = {} 239 | tap_stream_id = "contacts" 240 | 241 | xero_client = client_.XeroClient(config) 242 | xero_client.access_token = "123" 243 | xero_client.tenant_id = "123" 244 | 245 | try: 246 | xero_client.filter(tap_stream_id) 247 | except client_.XeroNotFoundError as e: 248 | expected_error_message = "HTTP-error-code: 404, Error: The resource you have specified cannot be found." 249 | 250 | # Verifying the message formed for the custom exception 251 | self.assertEquals(str(e), expected_error_message) 252 | pass 253 | 254 | @mock.patch('requests.Request', side_effect=mocked_precondition_failed_412_error) 255 | def test_precondition_failed_412_error(self, mocked_session, mocked_precondition_failed_412_error): 256 | config = {} 257 | tap_stream_id = "contacts" 258 | 259 | xero_client = client_.XeroClient(config) 260 | xero_client.access_token = "123" 261 | xero_client.tenant_id = "123" 262 | 263 | try: 264 | xero_client.filter(tap_stream_id) 265 | except client_.XeroPreConditionFailedError as e: 266 | expected_error_message = "HTTP-error-code: 412, Error: One or more conditions given in the request header fields were invalid." 267 | 268 | # Verifying the message formed for the custom exception 269 | self.assertEquals(str(e), expected_error_message) 270 | pass 271 | 272 | @mock.patch('requests.Request', side_effect=mocked_internalservererror_500_error) 273 | def test_internalservererror_500_error(self, mocked_session, mocked_internalservererror_500_error): 274 | config = {} 275 | tap_stream_id = "contacts" 276 | 277 | xero_client = client_.XeroClient(config) 278 | xero_client.access_token = "123" 279 | xero_client.tenant_id = "123" 280 | 281 | try: 282 | xero_client.filter(tap_stream_id) 283 | except client_.XeroInternalError as e: 284 | expected_error_message = "HTTP-error-code: 500, Error: An unhandled error with the Xero API. Contact the Xero API team if problems persist." 285 | 286 | # Verifying the message formed for the custom exception 287 | self.assertEquals(str(e), expected_error_message) 288 | pass 289 | 290 | 291 | @mock.patch('requests.Request', side_effect=mocked_notimplemented_501_error) 292 | def test_notimplemented_501_error(self, mocked_session, mocked_notimplemented_501_error): 293 | config = {} 294 | tap_stream_id = "contacts" 295 | 296 | xero_client = client_.XeroClient(config) 297 | xero_client.access_token = "123" 298 | xero_client.tenant_id = "123" 299 | 300 | try: 301 | xero_client.filter(tap_stream_id) 302 | except client_.XeroNotImplementedError as e: 303 | expected_error_message = "HTTP-error-code: 501, Error: The method you have called has not been implemented." 304 | 305 | # Verifying the message formed for the custom exception 306 | self.assertEquals(str(e), expected_error_message) 307 | pass 308 | 309 | @mock.patch('requests.Request', side_effect=mocked_not_available_503_error) 310 | def test_not_available_503_error(self, mocked_session, mocked_not_available_503_error): 311 | config = {} 312 | tap_stream_id = "contacts" 313 | 314 | xero_client = client_.XeroClient(config) 315 | xero_client.access_token = "123" 316 | xero_client.tenant_id = "123" 317 | 318 | try: 319 | xero_client.filter(tap_stream_id) 320 | except client_.XeroNotAvailableError as e: 321 | expected_error_message = "HTTP-error-code: 503, Error: API service is currently unavailable." 322 | 323 | # Verifying the message formed for the custom exception 324 | self.assertEquals(str(e), expected_error_message) 325 | pass 326 | 327 | 328 | @mock.patch('requests.Request', side_effect=mocked_failed_429_request_in_day) 329 | def test_too_many_requests_429_in_day_error(self, mocked_session, mocked_failed_429_request_in_day): 330 | config = {} 331 | tap_stream_id = "contacts" 332 | 333 | xero_client = client_.XeroClient(config) 334 | xero_client.access_token = "123" 335 | xero_client.tenant_id = "123" 336 | 337 | try: 338 | # Verifying if the custom exception 'XeroTooManyError' is raised on receiving status code 429 with daily limit exceeded 339 | filter_func_exec = xero_client.filter(tap_stream_id) 340 | except client_.XeroTooManyError as e: 341 | expected_error_message = "HTTP-error-code: 429, Error: The API rate limit for your organisation/application pairing has been exceeded. Please retry after 1000 seconds" 342 | 343 | # Verifying the message formed for the custom exception 344 | self.assertEquals(str(e), expected_error_message) 345 | pass 346 | 347 | 348 | @mock.patch('requests.Request', side_effect=mocked_failed_429_request_in_minute) 349 | def test_too_many_requests_429_in_minute_error(self, mocked_session, mocked_failed_429_request_in_minute): 350 | config = {} 351 | tap_stream_id = "contacts" 352 | 353 | xero_client = client_.XeroClient(config) 354 | xero_client.access_token = "123" 355 | xero_client.tenant_id = "123" 356 | 357 | try: 358 | # Verifying if the custom exception 'XeroTooManyInMinuteError' is raised on receiving status code 429 with minute limit exceeded 359 | filter_func_exec = xero_client.filter(tap_stream_id) 360 | except client_.XeroTooManyInMinuteError as e: 361 | expected_error_message = "HTTP-error-code: 429, Error: The API rate limit for your organisation/application pairing has been exceeded. Please retry after 5 seconds" 362 | 363 | # Verifying the message formed for the custom exception 364 | self.assertEquals(str(e), expected_error_message) 365 | pass 366 | 367 | 368 | @mock.patch('requests.Request', side_effect=mocked_failed_429_request_in_day) 369 | def test_too_many_requests_in_day_429_not_backoff_behavior(self, mocked_session, mocked_failed_429_request_in_day): 370 | config = {} 371 | tap_stream_id = "contacts" 372 | 373 | xero_client = client_.XeroClient(config) 374 | xero_client.access_token = "123" 375 | xero_client.tenant_id = "123" 376 | try: 377 | filter_func_exec = xero_client.filter(tap_stream_id) 378 | except (requests.HTTPError, client_.XeroTooManyError) as e: 379 | pass 380 | 381 | #Verify daily limit should not backoff 382 | self.assertEqual(mocked_failed_429_request_in_day.call_count, 1) 383 | self.assertEqual(mocked_session.call_count, 1) 384 | 385 | 386 | @mock.patch('requests.Request', side_effect=mocked_failed_429_request_in_minute) 387 | def test_too_many_requests_in_minute_429_backoff_behavior(self, mocked_session, mocked_failed_429_request_in_minute): 388 | config = {} 389 | tap_stream_id = "contacts" 390 | 391 | xero_client = client_.XeroClient(config) 392 | xero_client.access_token = "123" 393 | xero_client.tenant_id = "123" 394 | try: 395 | filter_func_exec = xero_client.filter(tap_stream_id) 396 | except (requests.HTTPError, client_.XeroTooManyInMinuteError) as e: 397 | pass 398 | 399 | self.assertEqual(mocked_failed_429_request_in_minute.call_count, 3) 400 | self.assertEqual(mocked_session.call_count, 3) 401 | 402 | @mock.patch('requests.Request', side_effect=mocked_internalservererror_500_error) 403 | def test_internalservererror_500_backoff_behaviour(self, mocked_session, mocked_internalservererror_500_error): 404 | config = {} 405 | tap_stream_id = "contacts" 406 | 407 | xero_client = client_.XeroClient(config) 408 | xero_client.access_token = "123" 409 | xero_client.tenant_id = "123" 410 | try: 411 | filter_func_exec = xero_client.filter(tap_stream_id) 412 | except (requests.HTTPError, client_.XeroInternalError) as e: 413 | pass 414 | 415 | self.assertEqual(mocked_internalservererror_500_error.call_count, 3) 416 | self.assertEqual(mocked_session.call_count, 3) 417 | 418 | 419 | 420 | @mock.patch('requests.Session.send', side_effect=mocked_session) 421 | class TestCheckPlatformAccessBehavior(unittest.TestCase): 422 | 423 | @mock.patch('requests.Session.post', side_effect=mocked_unauthorized_401_error) 424 | def test_check_unauthorized_401_error_in_discovery_mode(self, mocked_unauthorized_401_error, mocked_session): 425 | config = { 426 | "client_id": "123", 427 | "client_secret": "123", 428 | "refresh_token": "123", 429 | "tenant_id": "123" 430 | } 431 | config_path = "" 432 | 433 | xero_client = client_.XeroClient(config) 434 | 435 | try: 436 | xero_client.check_platform_access(config, config_path) 437 | except client_.XeroUnauthorizedError as e: 438 | expected_message = "HTTP-error-code: 401, Error: Invalid authorization credentials." 439 | self.assertEqual(str(e) ,expected_message) 440 | 441 | 442 | @mock.patch("tap_xero.client.XeroClient.refresh_credentials") 443 | @mock.patch('requests.Request', side_effect=mocked_forbidden_403_exception) 444 | def test_check_forbidden_403_error_in_discovery_mode(self, mocked_refresh_credentials, mocked_session, mocked_forbidden_403_exception): 445 | 446 | mocked_refresh_credentials.return_value = "" 447 | config = {} 448 | config_path = "" 449 | 450 | xero_client = client_.XeroClient(config) 451 | xero_client.access_token = "123" 452 | xero_client.tenant_id = "123" 453 | 454 | try: 455 | xero_client.check_platform_access(config, config_path) 456 | except client_.XeroForbiddenError as e: 457 | expected_message = "HTTP-error-code: 403, Error: User doesn't have permission to access the resource." 458 | self.assertEqual(str(e) ,expected_message) 459 | 460 | 461 | @mock.patch("tap_xero.client.XeroClient.refresh_credentials") 462 | @mock.patch('requests.Request', side_effect=mocked_badrequest_400_error) 463 | def test_badrequest_400_error_in_discovery_mode(self, mocked_refresh_credentials, mocked_session, mocked_badrequest_400_error): 464 | 465 | mocked_refresh_credentials.return_value = "" 466 | config = {} 467 | config_path = "" 468 | 469 | xero_client = client_.XeroClient(config) 470 | xero_client.access_token = "123" 471 | xero_client.tenant_id = "123" 472 | 473 | try: 474 | xero_client.check_platform_access(config, config_path) 475 | except client_.XeroBadRequestError as e: 476 | expected_message = "HTTP-error-code: 400, Error: A validation exception has occurred." 477 | self.assertEqual(str(e), expected_message) 478 | 479 | 480 | @mock.patch("tap_xero.client.XeroClient.refresh_credentials") 481 | @mock.patch('requests.Request', side_effect=mocked_notfound_404_error) 482 | def test_notfound_404_error_in_discovery_mode(self, mocked_refresh_credentials, mocked_session, mocked_notfound_404_error): 483 | 484 | mocked_refresh_credentials.return_value = "" 485 | config = {} 486 | config_path = "" 487 | 488 | xero_client = client_.XeroClient(config) 489 | xero_client.access_token = "123" 490 | xero_client.tenant_id = "123" 491 | 492 | try: 493 | xero_client.check_platform_access(config, config_path) 494 | except client_.XeroNotFoundError as e: 495 | expected_message = "HTTP-error-code: 404, Error: The resource you have specified cannot be found." 496 | self.assertEqual(str(e), expected_message) 497 | 498 | 499 | @mock.patch("tap_xero.client.XeroClient.refresh_credentials") 500 | @mock.patch('requests.Request', side_effect=mocked_precondition_failed_412_error) 501 | def test_precondition_failed_412_error_in_discovery_mode(self, mocked_refresh_credentials, mocked_session, mocked_precondition_failed_412_error): 502 | 503 | mocked_refresh_credentials.return_value = "" 504 | config = {} 505 | config_path = "" 506 | 507 | xero_client = client_.XeroClient(config) 508 | xero_client.access_token = "123" 509 | xero_client.tenant_id = "123" 510 | 511 | try: 512 | xero_client.check_platform_access(config, config_path) 513 | except client_.XeroPreConditionFailedError as e: 514 | expected_message = "HTTP-error-code: 412, Error: One or more conditions given in the request header fields were invalid." 515 | self.assertEqual(str(e), expected_message) 516 | 517 | 518 | @mock.patch("tap_xero.client.XeroClient.refresh_credentials") 519 | @mock.patch('requests.Request', side_effect=mocked_internalservererror_500_error) 520 | def test_internalservererror_500_error_in_discovery_mode(self, mocked_refresh_credentials, mocked_session, mocked_internalservererror_500_error): 521 | 522 | mocked_refresh_credentials.return_value = "" 523 | config = {} 524 | config_path = "" 525 | 526 | xero_client = client_.XeroClient(config) 527 | xero_client.access_token = "123" 528 | xero_client.tenant_id = "123" 529 | 530 | try: 531 | xero_client.check_platform_access(config, config_path) 532 | except client_.XeroInternalError as e: 533 | expected_message = "HTTP-error-code: 500, Error: An unhandled error with the Xero API. Contact the Xero API team if problems persist." 534 | self.assertEqual(str(e), expected_message) 535 | 536 | 537 | @mock.patch("tap_xero.client.XeroClient.refresh_credentials") 538 | @mock.patch('requests.Request', side_effect=mocked_notimplemented_501_error) 539 | def test_notimplemented_501_error_in_discovery_mode(self, mocked_refresh_credentials, mocked_session, mocked_notimplemented_501_error): 540 | 541 | mocked_refresh_credentials.return_value = "" 542 | config = {} 543 | config_path = "" 544 | 545 | xero_client = client_.XeroClient(config) 546 | xero_client.access_token = "123" 547 | xero_client.tenant_id = "123" 548 | 549 | try: 550 | xero_client.check_platform_access(config, config_path) 551 | except client_.XeroNotImplementedError as e: 552 | expected_message = "HTTP-error-code: 501, Error: The method you have called has not been implemented." 553 | self.assertEqual(str(e), expected_message) 554 | 555 | 556 | @mock.patch("tap_xero.client.XeroClient.refresh_credentials") 557 | @mock.patch('requests.Request', side_effect=mocked_not_available_503_error) 558 | def test_not_available_503_error_in_discovery_mode(self, mocked_refresh_credentials, mocked_session, mocked_not_available_503_error): 559 | 560 | mocked_refresh_credentials.return_value = "" 561 | config = {} 562 | config_path = "" 563 | 564 | xero_client = client_.XeroClient(config) 565 | xero_client.access_token = "123" 566 | xero_client.tenant_id = "123" 567 | 568 | try: 569 | xero_client.check_platform_access(config, config_path) 570 | except client_.XeroNotAvailableError as e: 571 | expected_message = "HTTP-error-code: 503, Error: API service is currently unavailable." 572 | self.assertEqual(str(e), expected_message) 573 | 574 | 575 | @mock.patch("tap_xero.client.XeroClient.refresh_credentials") 576 | @mock.patch('requests.Request', side_effect=mocked_failed_429_request_in_day) 577 | def test_too_many_requests_in_day_429_error_in_discovery_mode(self, mocked_refresh_credentials, mocked_session, mocked_failed_429_request_in_day): 578 | 579 | mocked_refresh_credentials.return_value = "" 580 | config = {} 581 | config_path = "" 582 | 583 | xero_client = client_.XeroClient(config) 584 | xero_client.access_token = "123" 585 | xero_client.tenant_id = "123" 586 | 587 | try: 588 | xero_client.check_platform_access(config, config_path) 589 | except client_.XeroTooManyError as e: 590 | expected_message = "HTTP-error-code: 429, Error: The API rate limit for your organisation/application pairing has been exceeded. Please retry after 1000 seconds" 591 | self.assertEqual(str(e), expected_message) 592 | 593 | 594 | @mock.patch("tap_xero.client.XeroClient.refresh_credentials") 595 | @mock.patch('requests.Request', side_effect=mocked_failed_429_request_in_minute) 596 | def test_too_many_requests_in_minute_429_error_in_discovery_mode(self, mocked_refresh_credentials, mocked_session, mocked_failed_429_request_in_minute): 597 | 598 | mocked_refresh_credentials.return_value = "" 599 | config = {} 600 | config_path = "" 601 | 602 | xero_client = client_.XeroClient(config) 603 | xero_client.access_token = "123" 604 | xero_client.tenant_id = "123" 605 | 606 | try: 607 | xero_client.check_platform_access(config, config_path) 608 | except client_.XeroTooManyInMinuteError as e: 609 | expected_message = "HTTP-error-code: 429, Error: The API rate limit for your organisation/application pairing has been exceeded. Please retry after 5 seconds" 610 | self.assertEqual(str(e), expected_message) 611 | 612 | 613 | @mock.patch("tap_xero.client.XeroClient.refresh_credentials") 614 | @mock.patch('requests.Request', side_effect=mocked_failed_429_request_in_day) 615 | def test_too_many_requests_in_day_429_not_backoff_behavior_discovery_mode(self, mocked_refresh_credentials, mocked_session, mocked_failed_429_request_in_day): 616 | 617 | mocked_refresh_credentials.return_value = "" 618 | config = {} 619 | config_path = "" 620 | 621 | xero_client = client_.XeroClient(config) 622 | xero_client.access_token = "123" 623 | xero_client.tenant_id = "123" 624 | 625 | try: 626 | xero_client.check_platform_access(config, config_path) 627 | except (requests.HTTPError, client_.XeroTooManyError) as e: 628 | pass 629 | 630 | #Verify daily limit should not backoff 631 | self.assertEqual(mocked_failed_429_request_in_day.call_count, 1) 632 | self.assertEqual(mocked_session.call_count, 1) 633 | 634 | 635 | @mock.patch("tap_xero.client.XeroClient.refresh_credentials") 636 | @mock.patch('requests.Request', side_effect=mocked_failed_429_request_in_minute) 637 | def test_too_many_requests_in_minute_429_backoff_behavior_discovery_mode(self, mocked_refresh_credentials, mocked_session, mocked_failed_429_request_in_minute): 638 | 639 | mocked_refresh_credentials.return_value = "" 640 | config = {} 641 | config_path = "" 642 | 643 | xero_client = client_.XeroClient(config) 644 | xero_client.access_token = "123" 645 | xero_client.tenant_id = "123" 646 | 647 | try: 648 | xero_client.check_platform_access(config, config_path) 649 | except (requests.HTTPError, client_.XeroTooManyInMinuteError) as e: 650 | pass 651 | 652 | self.assertEqual(mocked_failed_429_request_in_minute.call_count, 3) 653 | self.assertEqual(mocked_session.call_count, 3) 654 | 655 | 656 | @mock.patch("tap_xero.client.XeroClient.refresh_credentials") 657 | @mock.patch('requests.Request', side_effect=mocked_internalservererror_500_error) 658 | def test_internalservererror_500_backoff_behaviour_discovery_mode(self, mocked_refresh_credentials, mocked_session, mocked_internalservererror_500_error): 659 | 660 | mocked_refresh_credentials.return_value = "" 661 | config = {} 662 | config_path = "" 663 | 664 | xero_client = client_.XeroClient(config) 665 | xero_client.access_token = "123" 666 | xero_client.tenant_id = "123" 667 | 668 | try: 669 | xero_client.check_platform_access(config, config_path) 670 | except (requests.HTTPError, client_.XeroInternalError) as e: 671 | pass 672 | 673 | self.assertEqual(mocked_internalservererror_500_error.call_count, 3) 674 | self.assertEqual(mocked_session.call_count, 3) 675 | 676 | 677 | @mock.patch('requests.Session.post', side_effect=mock_successful_session_post) 678 | @mock.patch('tap_xero.client.update_config_file') 679 | @mock.patch('requests.Request', side_effect=mock_successful_request) 680 | def test_check_success_200_in_discovery_mode(self, mock_successful_session_post, mocked_update_config_file, mocked_session, mock_successful_request): 681 | 682 | mocked_update_config_file.return_value = "" 683 | 684 | config = { 685 | "client_id": "123", 686 | "client_secret": "123", 687 | "refresh_token": "123", 688 | "tenant_id": "123" 689 | } 690 | config_path = "" 691 | 692 | xero_client = client_.XeroClient(config) 693 | expected_access_token = "123" 694 | expected_refresh_token = "345" 695 | 696 | xero_client.check_platform_access(config, config_path) 697 | 698 | self.assertEqual(xero_client.access_token, expected_access_token) 699 | self.assertEqual(config["refresh_token"], expected_refresh_token) 700 | self.assertEqual(xero_client.tenant_id, config["tenant_id"]) 701 | --------------------------------------------------------------------------------