From ac3c9d279ddf975c13ab478f87df22ede9ab3348 Mon Sep 17 00:00:00 2001 From: Richard O'Dwyer Date: Sun, 8 Jun 2025 19:05:05 +0100 Subject: [PATCH 1/4] Fix lib versions so the app will run --- requirements.txt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 427189d..5c3cad1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -flask -flask-session==0.3.2 +werkzeug==2.0.3 +flask==2.0.3 +flask-session==0.4.0 flask-oauthlib==0.9.6 -xero-python==6.1.0 \ No newline at end of file +xero-python==6.1.0 From f1b8f60c5bc90112f485928a165f1e14f4e7c313 Mon Sep 17 00:00:00 2001 From: Richard O'Dwyer Date: Mon, 9 Jun 2025 15:05:52 +0100 Subject: [PATCH 2/4] wip save, reconilate wip --- app.py | 119 ++++++++++++++++++++++++++++++-------------------------- app2.py | 105 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 56 deletions(-) create mode 100644 app2.py diff --git a/app.py b/app.py index a0c9fb7..40b3881 100644 --- a/app.py +++ b/app.py @@ -63,6 +63,7 @@ access_token_url="https://identity.xero.com/connect/token", refresh_token_url="https://identity.xero.com/connect/token", scope="files.read profile files accounting.contacts.read payroll.settings accounting.attachments accounting.journals.read accounting.attachments.read projects.read accounting.transactions.read accounting.settings.read payroll.payslip payroll.payruns payroll.employees accounting.transactions assets accounting.contacts accounting.budgets.read offline_access assets.read payroll.timesheets projects openid email accounting.reports.read accounting.settings", + #accounting.reports.bankstatement.read # "paymentservices " # "finance.bankstatementsplus.read finance.cashvalidation.read finance.statements.read finance.accountingactivity.read", ) # type: OAuth2Application @@ -95,9 +96,15 @@ def xero_token_required(function): @wraps(function) def decorator(*args, **kwargs): xero_token = obtain_xero_oauth2_token() + print('xero_token', xero_token) if not xero_token: return redirect(url_for("login", _external=True)) + from app2 import find_matching_bank_transaction + xero_tenant_id = get_xero_tenant_id() + matches, error = find_matching_bank_transaction(xero_tenant_id, api_client) + print('matches', matches) + print('error', error) return function(*args, **kwargs) return decorator @@ -1275,7 +1282,7 @@ def accounting_bank_transaction_attachment_create_by_file_name(): getvalue(created_bank_transaction_attachments_by_file_name, "attachments.0.url", "") ) json = serialize_model(created_bank_transaction_attachments_by_file_name) - + #[/BANKTRANSACTIONATTACHMENTS:CREATEBYFILENAME] return render_template( @@ -1970,7 +1977,7 @@ def accounting_bank_transfer_attachment_create_by_file_name(): getvalue(created_bank_transfer_attachments_by_file_name, "attachments.0.url", "") ) json = serialize_model(created_bank_transfer_attachments_by_file_name) - + #[/BANKTRANSFERATTACHMENTS:CREATEBYFILENAME] return render_template( @@ -2513,7 +2520,7 @@ def accounting_branding_theme_payment_service_read_all(): code = get_code_snippet("BRANDINGTHEMEPAYMENTSERVICES","READ_ALL") xero_tenant_id = get_xero_tenant_id() accounting_api = AccountingApi(api_client) - + try: read_branding_themes = accounting_api.get_branding_themes( xero_tenant_id @@ -3123,7 +3130,7 @@ def accounting_contact_read_one_by_contact_number(): # getvalue(created_bank_transaction_attachments_by_file_name, "attachments.0.url", "") # ) # json = serialize_model(created_bank_transaction_attachments_by_file_name) - + # #[/BANKTRANSACTIONATTACHMENTS:CREATEBYFILENAME] # return render_template( @@ -3832,13 +3839,13 @@ def accounting_credit_note_allocation_create(): invoice = Invoice( invoice_id = invoice_id) - + allocation = Allocation( amount = 1.0, date = curr_date, invoice = invoice) - - allocations = Allocations( + + allocations = Allocations( allocations = [allocation]) try: @@ -4079,7 +4086,7 @@ def accounting_credit_note_allocation_create(): # getvalue(created_bank_transaction_attachments_by_file_name, "attachments.0.url", "") # ) # json = serialize_model(created_bank_transaction_attachments_by_file_name) - + # #[/BANKTRANSACTIONATTACHMENTS:CREATEBYFILENAME] # return render_template( @@ -4514,7 +4521,7 @@ def accounting_expense_claim_create(): except AccountingBadRequestException as exception: output = "Error: " + exception.reason json = jsonify(exception.error_data) - + try: read_receipts = accounting_api.get_receipts( xero_tenant_id @@ -4531,20 +4538,20 @@ def accounting_expense_claim_create(): user = User( user_id = user_id) - + receipt = Receipt( receipt_id = receipt_id, date = curr_date) - + receipts = [] receipts.append(receipt) - + expense_claim = ExpenseClaim( status = "SUBMITTED", user = user, receipts = receipts) - - expense_claims = ExpenseClaims( + + expense_claims = ExpenseClaims( expense_claims = [expense_claim]) try: @@ -4580,7 +4587,7 @@ def accounting_expense_claim_update(): except AccountingBadRequestException as exception: output = "Error: " + exception.reason json = jsonify(exception.error_data) - + try: read_receipts = accounting_api.get_receipts( xero_tenant_id @@ -4589,7 +4596,7 @@ def accounting_expense_claim_update(): except AccountingBadRequestException as exception: output = "Error: " + exception.reason json = jsonify(exception.error_data) - + try: read_expense_claims = accounting_api.get_expense_claims( xero_tenant_id @@ -4606,20 +4613,20 @@ def accounting_expense_claim_update(): user = User( user_id = user_id) - + receipt = Receipt( receipt_id = receipt_id, date = curr_date) - + receipts = [] receipts.append(receipt) - + expense_claim = ExpenseClaim( status = "PAID", user = user, receipts = receipts) - - expense_claims = ExpenseClaims( + + expense_claims = ExpenseClaims( expense_claims = [expense_claim]) print(xero_tenant_id, expense_claim_id, expense_claims) @@ -5092,7 +5099,7 @@ def accounting_invoice_attachment_create_by_file_name(): getvalue(created_invoice_attachments_by_file_name, "attachments.0.url", "") ) json = serialize_model(created_invoice_attachments_by_file_name) - + #[/INVOICEATTACHMENTS:CREATEBYFILENAME] return render_template( @@ -5960,7 +5967,7 @@ def accounting_manual_journals_read_one(): # getvalue(created_bank_transaction_attachments_by_file_name, "attachments.0.url", "") # ) # json = serialize_model(created_bank_transaction_attachments_by_file_name) - + # #[/BANKTRANSACTIONATTACHMENTS:CREATEBYFILENAME] # return render_template( @@ -7153,7 +7160,7 @@ def accounting_quotes_create(): # getvalue(created_bank_transaction_attachments_by_file_name, "attachments.0.url", "") # ) # json = serialize_model(created_bank_transaction_attachments_by_file_name) - + # #[/BANKTRANSACTIONATTACHMENTS:CREATEBYFILENAME] # return render_template( @@ -7317,30 +7324,30 @@ def accounting_receipts_create(): xero_tenant_id = get_xero_tenant_id() accounting_api = AccountingApi(api_client) unitdp = 4 - + contact = Contact( contact_id = getvalue(read_contacts, "contacts.0.contact_id", "")) - + user = User( user_id = getvalue(read_users, "users.0.user_id", "")) - + line_item = LineItem( description = "Foobar", quantity = 1.0, unit_amount = 20.0, account_code = "300") - + line_items = [] line_items.append(line_item) - + receipt = Receipt( contact = contact, user = user, line_items = line_items, line_amount_types = LineAmountTypes.INCLUSIVE, status = "DRAFT") - - receipts = Receipts( + + receipts = Receipts( receipts = [receipt]) try: @@ -7579,7 +7586,7 @@ def accounting_receipts_create(): # getvalue(created_bank_transaction_attachments_by_file_name, "attachments.0.url", "") # ) # json = serialize_model(created_bank_transaction_attachments_by_file_name) - + # #[/BANKTRANSACTIONATTACHMENTS:CREATEBYFILENAME] # return render_template( @@ -7938,7 +7945,7 @@ def accounting_repeating_invoices_create_history(): history_record = HistoryRecord( details = "Hello World") - history_records = HistoryRecords( + history_records = HistoryRecords( history_records = [history_record]) try: @@ -8177,7 +8184,7 @@ def accounting_repeating_invoices_create_history(): # getvalue(created_bank_transaction_attachments_by_file_name, "attachments.0.url", "") # ) # json = serialize_model(created_bank_transaction_attachments_by_file_name) - + # #[/BANKTRANSACTIONATTACHMENTS:CREATEBYFILENAME] # return render_template( @@ -8328,7 +8335,7 @@ def accounting_repeating_invoices_create_history(): # getReportBalanceSheet x # getReportBankSummary x # getReportBASorGSTList x -# getReportBASorGST x +# getReportBASorGST x # getReportBudgetSummary x # getReportExecutiveSummary x # getReportProfitAndLoss x @@ -8702,13 +8709,13 @@ def accounting_tax_rate_create(): rate = 20.00) #report_tax_type is invalid for US orgs. - + tax_rate = TaxRate( - name = "Example Tax Rate", - report_tax_type = "INPUT", + name = "Example Tax Rate", + report_tax_type = "INPUT", tax_components = [tax_component]) - - tax_rates = TaxRates( + + tax_rates = TaxRates( tax_rates = [tax_rate]) try: @@ -8748,13 +8755,13 @@ def accounting_tax_rate_update(): rate = 20.00) #report_tax_type is invalid for US orgs. - + tax_rate = TaxRate( - name = getvalue(read_tax_rates, "tax_rates.0.name", ""), + name = getvalue(read_tax_rates, "tax_rates.0.name", ""), tax_components = [updated_tax_component]) - - tax_rates = TaxRates( + + tax_rates = TaxRates( tax_rates = [tax_rate]) try: @@ -9693,7 +9700,7 @@ def projects_task_update(): except AccountingBadRequestException as exception: output = "Error: " + exception.reason json = jsonify(exception.error_data) - + try: read_tasks = project_api.get_tasks( xero_tenant_id, project_id=project_id @@ -9764,7 +9771,7 @@ def projects_task_delete(): except AccountingBadRequestException as exception: output = "Error: " + exception.reason json = jsonify(exception.error_data) - + try: read_tasks = project_api.get_tasks( xero_tenant_id, project_id=project_id @@ -11955,7 +11962,7 @@ def files_file_read_all(): #[FILE:READ_ALL] try: read_files = files_api.get_files( - xero_tenant_id, + xero_tenant_id, ) except AccountingBadRequestException as exception: output = "Error: " + exception.reason @@ -11977,16 +11984,16 @@ def files_file_read_one(): xero_tenant_id = get_xero_tenant_id() files_api = FilesApi(api_client) accounting_api = AccountingApi(api_client) - + try: read_files = files_api.get_files( - xero_tenant_id, + xero_tenant_id, ) file_id = getvalue(read_files, "items.0.id", "") except AccountingBadRequestException as exception: output = "Error: " + exception.reason json = jsonify(exception.error_data) - + #[FILE:READ_ONE] try: read_file = files_api.get_file( @@ -12022,9 +12029,9 @@ def files_file_upload(): try: file_object = files_api.upload_file( - xero_tenant_id, - name = name, - filename= filename, + xero_tenant_id, + name = name, + filename= filename, mime_type = mime_type, body=body ) @@ -12052,7 +12059,7 @@ def files_folder_read_all(): #[FOLDER:READ_ALL] try: folders = files_api.get_folders( - xero_tenant_id, + xero_tenant_id, ) except AccountingBadRequestException as exception: output = "Error: " + exception.reason @@ -12077,13 +12084,13 @@ def files_folder_read_one(): try: read_folders = files_api.get_folders( - xero_tenant_id, + xero_tenant_id, ) folder_id = getvalue(read_folders, "1.id", "") except AccountingBadRequestException as exception: output = "Error: " + exception.reason json = jsonify(exception.error_data) - + #[FOLDER:READ_ONE] try: read_folder = files_api.get_folder( diff --git a/app2.py b/app2.py new file mode 100644 index 0000000..3b939f5 --- /dev/null +++ b/app2.py @@ -0,0 +1,105 @@ +from datetime import datetime, timedelta +from xero_python.accounting import AccountingApi + + +from xero_python.accounting import AccountingApi, BankTransaction, Contact, LineItem, Account + + +from datetime import date +from xero_python.accounting import BankTransactions +""" + +Sort of auto-reconcile a bank transaction: + +bank_transaction = BankTransaction( + type="SPEND", + contact=Contact( + name="SMART Agency" + ), + line_items=[ + LineItem( + description="Payment to SMART Agency 2", + quantity=1, + unit_amount=4500.00, + account_code="400" + ) + ], + bank_account=Account( + code="090" + ), + date=date(2025, 5, 30), + reference="0195 0210" +) + +response = accounting_api.create_bank_transactions( + xero_tenant_id=xero_tenant_id, + bank_transactions=BankTransactions(bank_transactions=[bank_transaction]) +) + +accounting_api.get_bank_transactions(xero_tenant_id, where='Total=4500 && status!="DELETED"').bank_transactions[1] + +""" + + +def find_matching_bank_transaction(xero_tenant_id, api_client): + """ + Search for bank transactions matching specific amount and date. + Returns a tuple of (matches, error_message) + """ + accounting_api = AccountingApi(api_client) + + # Search criteria + target_date = datetime(2025, 5, 30) + target_amount = 4500.00 + + # Allow for some flexibility in amount (±1%) + min_amount = target_amount * 0.99 + max_amount = target_amount * 1.01 + + try: + # Query Xero API for unreconciled transactions + transactions = accounting_api.get_bank_transactions( + xero_tenant_id, + where="IsReconciled==false", + # where="Status==\"AUTHORISED\" AND IsReconciled==false", + order="Date DESC" + ) + + print("Found transactionsc count:", len(transactions.bank_transactions)) + print("Found transactions:", transactions.to_dict()) + + # Format matches + matches = [] + for tx in transactions.bank_transactions: + assert not tx.is_reconciled + if tx.type != 'SPEND' or tx.status == 'DELETED': + print('skipping', tx.type) + continue + matches.append({ + 'contact_id': tx.contact.contact_id, + 'contact_name': tx.contact.name, + 'bank_transaction_id': tx.bank_transaction_id, + 'total': tx.total, + 'reference': tx.reference, + 'status': tx.status, + 'type': tx.type, + 'date': tx.date.isoformat(), + 'is_reconciled': tx.is_reconciled, + }) + # matches.append({ + # 'transaction_id': tx.bank_transaction_id, + # 'date': tx.date.strftime('%Y-%m-%d') if tx.date else 'N/A', + # 'amount': float(tx.total) if tx.total else 0, + # 'contact_name': tx.contact.contact_name if tx.contact and tx.contact.contact_name else 'No Contact', + # 'reference': tx.reference if tx.reference else 'No Reference', + # 'is_reconciled': tx.is_reconciled if hasattr(tx, 'is_reconciled') else False + # }) + + print('num matches', len(matches)) + import ipdb; ipdb.set_trace() + + return matches, None + + except Exception as e: + print(f"Error details: {str(e)}") + return None, f"Unexpected error: {str(e)}" From b1b52978499046e81aa17592f465b36f1763a72f Mon Sep 17 00:00:00 2001 From: Richard O'Dwyer Date: Mon, 9 Jun 2025 16:32:15 +0100 Subject: [PATCH 3/4] wip, recon check works --- .claude/settings.local.json | 8 ++ CLAUDE.md | 69 +++++++++++++ app.py | 26 ++++- app2.py | 157 +++++++++++++++++++--------- email-sally.md | 0 emails.md | 197 ++++++++++++++++++++++++++++++++++++ 6 files changed, 404 insertions(+), 53 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md create mode 100644 email-sally.md create mode 100644 emails.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1d2f67b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(ls:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..73ed9f2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Flask web application that demonstrates OAuth 2.0 authentication with the Xero API. It serves as a companion app showing how to connect to Xero organizations and make real API calls to various Xero endpoints. + +## Development Environment Setup + +### Prerequisites +- Python 3.5+ +- Git +- SSH keys configured for GitHub access (required for Flask-Session dependency) + +### Local Development Commands +```bash +# Create and activate virtual environment +python3 -m venv venv +source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt + +# Run the main application +python3 app.py + +# Application will be available at http://localhost:5000/login +``` + +## Configuration + +The application requires a `config.py` file in the root directory with Xero API credentials: +```python +CLIENT_ID = "...client id string..." +CLIENT_SECRET = "...client secret string..." +STATE = "...my super secure state..." +``` + +Use `example_config.py` as a template. The STATE field cannot be empty. + +## Architecture + +### Core Files +- `app.py`: Main Flask application with OAuth2 flow and API demonstrations +- `app2.py`: Additional utility functions for bank transaction matching +- `utils.py`: JSON serialization utilities and custom encoders for Xero Python SDK models +- `default_settings.py`: Flask configuration for development environment +- `logging_settings.py`: Logging configuration for debugging API calls + +### Key Components +- **OAuth2 Flow**: Implemented using flask-oauthlib with token persistence via flask-session +- **Token Management**: Automatic refresh of expired access tokens +- **API Client**: Xero Python SDK v6.1.0 with comprehensive API coverage +- **Session Storage**: File-based session storage in `cache/` directory + +### Xero API Integration +The app demonstrates integration with multiple Xero APIs: +- Accounting API (invoices, contacts, organizations) +- Assets API +- Project API +- Payroll APIs (AU, UK, NZ) +- Files API +- Finance API + +Token exchange between flask-oauthlib and xero-python SDK is handled by decorator functions that persist tokens in Flask sessions. + +## Testing with Demo Company +Always use Xero's Demo Company for testing to avoid affecting real data. The application includes functionality to connect to organizations and perform various API operations safely in the demo environment. \ No newline at end of file diff --git a/app.py b/app.py index 40b3881..25d6e7d 100644 --- a/app.py +++ b/app.py @@ -100,11 +100,27 @@ def decorator(*args, **kwargs): if not xero_token: return redirect(url_for("login", _external=True)) - from app2 import find_matching_bank_transaction + from app2 import find_or_create_bank_transaction + from datetime import date + target_date = date(2025, 5, 30) + target_amount = '2000.00' xero_tenant_id = get_xero_tenant_id() - matches, error = find_matching_bank_transaction(xero_tenant_id, api_client) - print('matches', matches) - print('error', error) + # matches, error = find_or_create_bank_transaction( + # xero_tenant_id=xero_tenant_id, + # api_client=api_client, + # target_date=target_date, + # target_amount=target_amount, + # ) + # print('matches', matches) + # print('error', error) + bank, inv = find_or_create_bank_transaction( + xero_tenant_id=xero_tenant_id, + api_client=api_client, + target_date=date(2025, 6, 3), + target_amount='1000.00', + ) + # if either set, its reconcilated. + import ipdb; ipdb.set_trace() return function(*args, **kwargs) return decorator @@ -177,7 +193,7 @@ def accounting_account_read_all(): code = get_code_snippet("ACCOUNTS","READ_ALL") #[ACCOUNTS:READ_ALL] - xero_tenant_id = get_xero_tenant_id() + xero_tenant_id = get_xero_tenant_id(kwargs=True) accounting_api = AccountingApi(api_client) order = 'Name ASC' diff --git a/app2.py b/app2.py index 3b939f5..31435b4 100644 --- a/app2.py +++ b/app2.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import timedelta from xero_python.accounting import AccountingApi @@ -41,64 +41,125 @@ """ -def find_matching_bank_transaction(xero_tenant_id, api_client): +def find_or_create_bank_transaction(xero_tenant_id, api_client, target_date, target_amount): """ - Search for bank transactions matching specific amount and date. - Returns a tuple of (matches, error_message) + Search for bank transactions matching specific criteria using where filter and pagination. + If no matching transaction is found, create a new one. + Based on Sally's suggestion from Xero support. + Returns a tuple of (result, error_message) """ accounting_api = AccountingApi(api_client) - # Search criteria - target_date = datetime(2025, 5, 30) - target_amount = 4500.00 - - # Allow for some flexibility in amount (±1%) - min_amount = target_amount * 0.99 - max_amount = target_amount * 1.01 + # Hardcoded search criteria as requested + target_reference = "SMART Agency" try: - # Query Xero API for unreconciled transactions + # Search for existing bank transactions using where filter as Sally suggested + where_filter = f'Total=={target_amount} && Status!="DELETED"' + print(f"Searching for bank transactions with filter: {where_filter}") + # import ipdb; ipdb.set_trace() transactions = accounting_api.get_bank_transactions( xero_tenant_id, - where="IsReconciled==false", - # where="Status==\"AUTHORISED\" AND IsReconciled==false", - order="Date DESC" + where=( + f'Total=={target_amount} && ' + # f'Total=={target_amount} && Status!="DELETED" && ' + f'Date>=DateTime({target_date.year},{target_date.month:02d},{target_date.day:02d})' + ), + order="Date DESC", + if_modified_since=target_date + ) + invoices = accounting_api.get_invoices( + xero_tenant_id, + where=( + f'Total=={target_amount} && ' + # f'Total=={target_amount} && Status!="DELETED" && ' + f'Date>=DateTime({target_date.year},{target_date.month:02d},{target_date.day:02d})' + ), + order="Date DESC", + ) + not_found = len(transactions.bank_transactions) == 0 and len(invoices.invoices) == 0 + return transactions.bank_transactions, invoices.invoices + if not_found: + print( + "!!!!!! NO matching transaction found. " + f"target_date: {target_date}, target_mount {target_amount}" + ) + else: + print( + "Found matching transaction." + f"target_date: {target_date}, target_mount {target_amount}" + ) + + return None, None + + print(f"Found {len(transactions.bank_transactions)} transactions matching amount criteria") + + # Look for matches with date (reference is editable so don't require exact match) + date_matches = [] + for tx in transactions.bank_transactions: + # Check if date matches - this is the primary criteria since reference is editable + if tx.date == target_date: + date_matches.append({ + 'bank_transaction_id': tx.bank_transaction_id, + 'total': tx.total, + 'reference': tx.reference, + 'contact_name': tx.contact.name if tx.contact else None, + 'date': tx.date.isoformat() if tx.date else None, + 'status': tx.status, + 'type': tx.type, + 'is_reconciled': tx.is_reconciled, + }) + + if date_matches: + print(f"Found {len(date_matches)} transactions matching date and amount") + return {'action': 'found', 'transactions': date_matches}, None + + # No matching transaction found, create a new one as Sally suggested + print("No matching transaction found. Creating new bank transaction...") + + bank_transaction = BankTransaction( + type="SPEND", + contact=Contact( + name="SMART Agency" + ), + line_items=[ + LineItem( + description="Payment to SMART Agency", + quantity=1, + unit_amount=target_amount, + account_code="400" # Default expense account + ) + ], + bank_account=Account( + code="090" # Default bank account + ), + date=target_date, + reference="SMART Agency" ) - print("Found transactionsc count:", len(transactions.bank_transactions)) - print("Found transactions:", transactions.to_dict()) + response = accounting_api.create_bank_transactions( + xero_tenant_id=xero_tenant_id, + bank_transactions=BankTransactions(bank_transactions=[bank_transaction]) + ) - # Format matches - matches = [] - for tx in transactions.bank_transactions: - assert not tx.is_reconciled - if tx.type != 'SPEND' or tx.status == 'DELETED': - print('skipping', tx.type) - continue - matches.append({ - 'contact_id': tx.contact.contact_id, - 'contact_name': tx.contact.name, - 'bank_transaction_id': tx.bank_transaction_id, - 'total': tx.total, - 'reference': tx.reference, - 'status': tx.status, - 'type': tx.type, - 'date': tx.date.isoformat(), - 'is_reconciled': tx.is_reconciled, - }) - # matches.append({ - # 'transaction_id': tx.bank_transaction_id, - # 'date': tx.date.strftime('%Y-%m-%d') if tx.date else 'N/A', - # 'amount': float(tx.total) if tx.total else 0, - # 'contact_name': tx.contact.contact_name if tx.contact and tx.contact.contact_name else 'No Contact', - # 'reference': tx.reference if tx.reference else 'No Reference', - # 'is_reconciled': tx.is_reconciled if hasattr(tx, 'is_reconciled') else False - # }) - - print('num matches', len(matches)) - import ipdb; ipdb.set_trace() - - return matches, None + if response.bank_transactions: + created_tx = response.bank_transactions[0] + result = { + 'action': 'created', + 'transaction': { + 'bank_transaction_id': created_tx.bank_transaction_id, + 'total': created_tx.total, + 'reference': created_tx.reference, + 'contact_name': created_tx.contact.name if created_tx.contact else None, + 'date': created_tx.date.isoformat() if created_tx.date else None, + 'status': created_tx.status, + 'type': created_tx.type, + } + } + print(f"Successfully created bank transaction: {created_tx.bank_transaction_id}") + return result, None + else: + return None, "Failed to create bank transaction" except Exception as e: print(f"Error details: {str(e)}") diff --git a/email-sally.md b/email-sally.md new file mode 100644 index 0000000..e69de29 diff --git a/emails.md b/emails.md new file mode 100644 index 0000000..836bbae --- /dev/null +++ b/emails.md @@ -0,0 +1,197 @@ +Thanks for the help Sally. I’ll give it a go. + +-------------------------------- + + +Hi Richard + +In that case you can search invoices and bank transactions using the where filter and pagination and then if there is no matching invoice or transaction, then you can create the transaction. + +Xero Developer Centre: BankTransactions + + +Kind regards + +Sally + + + +----- + +The issue is that the vast majority of invoices/receipts are likely not to be in Xero (I’m referring to expenses/purchases). +So at that moment in time, in Xero, that transaction would be under “Bank Statements” section only, I believe, since it’s not yet reconciled/linked to a Payment record. + +So the latter part of your suggestion makes sense, but not the first part. + +Thanks for the help, +Richard + + +-------------------------------- + + + +Hi Richard + +The best option would be to search for the invoice in Xero from the invoices endpoint and as long as you use pagination and/or include the status filter, you would retrieve details of any payments applied to the invoice. + +Customers can send you a copy of their bank statements, we are contractually not allowed to. + +Please note, for invoice payments you would use the payments endpoint not the bank transactions endpoint. + +Xero Developer Centre: +Invoices +Payments + + +Kind regards + +Sally + + +-------------------------------- + + + +Hi Richard + +Dext were using the bank statements report for many years before it was deprecated and so were allowed to keep their access. + +Due to contractual agreements with the banks who own the data, we can't offer the report to other apps. +Kind regards + +Sally + + + +-------------------------------- + + +Hello, + +I can see dext.com is using the bank statements report for their software (see attached OAuth screenshot). And I can see from their functionality that they are accessing the bank statement reports. + +Given that fact, can you enable it for my application? + +Thankyou + + +-------------------------------- + + + +Hi Richard + +The bank statements report endpoint is deprecated and currently bank statements can only be retrieved through the bank feeds API. The bank feeds API is restricted to banks and similar regulated financial bodies that have signed a partner agreement with us. + +Kind regards + + + +----- + + +Hi Xero Support, + + +I’m using your official Python SDK to access the Bank Statement Report, but I’m running into some issues. + + +I’m currently using the following scopes: + +``` + +[ + +"offline_access", + +"accounting.contacts", + +"accounting.transactions", + +"accounting.attachments", + +"accounting.settings", + +"accounting.reports.read" + +] + +``` + +When I try to call (via your Python lib): + +``` + +accounting_api.get_report_from_id( + +xero_tenant_id=tenant_id, + +report_id='BankStatement', + +) + +``` + +I get the following error: + +``` + +*** xero_python.exceptions.http_status_exceptions.HTTPStatusException: (401) + +Reason: Unauthorized + +HTTP response headers: + +Content-Type: application/json + +Content-Length: 152 + +Server: nginx + +WWW-Authenticate: insufficient_scope + +Xero-Correlation-Id: 6a5d6db8-9495-4e0b-98dd-8b97d2c0b1e0 + +X-AppMinLimit-Remaining: 9997 + +Expires: Sun, 08 Jun 2025 14:37:32 GMT + +Cache-Control: max-age=0, no-cache, no-store + +Pragma: no-cache + +Date: Sun, 08 Jun 2025 14:37:32 GMT + +Connection: keep-alive + +X-Client-TLS-ver: tls1.3 + +HTTP response body: + +{"Type":null,"Title":"Unauthorized","Status":401,"Detail":"AuthorizationUnsuccessful","Instance":"6a5d6db8-9495-4e0b-98dd-8b97d2c0b1e0","Extensions":{} } + +``` + + +To fix this, I tried adding the `accounting.reports.bankstatement.read` scope. However, when I add that scope and re-authorize, the OAuth process fails and redirects me to the following error URL: + + +``` + +https://login.xero.com/identity/error?errorId=CfDJ8FAUJPAPyNBGj391Oz_Gl3abzhs0D5m0BeXmTYpoOAW98wOSkj7LosNbZhb2Wg2bO0bDLK6PzPfZR7IEK2E5FhSKns96Q4tiAgJ6liWIUTaYYxsb35DZ4uN-iU8ZkyEVJ2gQWn43mcm6DxrKKnAxPJa73jnk_fF3PQNQXPsV74y3NbIj2QwX5Qxck5eHQMEFvgDA2Ip7GjImPiU5-4gTreub_wIP70QgIcZw64gTpT1xXq1nWYPKCELaCOxi86OlFiLr8hE2WiXLWhO3iLAjHb5OcNP5G7xoo-4t8tg71d0HG4azMALrVqSPR0JpT-ge7aOOCoQuhqE-OJqJOKeHYfybsuddEhqg6FchNzJabOMX_1K1lR0ztob-G-Bdgp_4TbKkOr2j8YvH11R4mAquxIWal10pNuzM0ovY8zw-jXk8yt9N2mp1umLzsEOkHTquIrkwvNYlnlrA6VsRyBBWWX4LYi5JBw2DE0CaX544qAmB9WeuSwdDlll8K5z7zDOlrDwIE7juv2SDhKc0Z-GDVhTABEuxfKWfkAUWPCa75pyBXh24lFt_HcFCCkZIC0etG4uxa42ROJC5IgItJCz0kBwLnHvHkFGGCWXibYHVSKYzAv9DBAoO70CL0UA8mhtvvw + +``` + + +Could you please advise on how to proceed? Should I be using a different scope, or is there an issue with the setup on my account? + + +Thanks for your help! + + +Best regards, + +Richard O'Dwyer + + From 966ddaf1f3def8da92daf01a75a3b433c79b768c Mon Sep 17 00:00:00 2001 From: Richard O'Dwyer Date: Thu, 12 Jun 2025 17:28:35 +0100 Subject: [PATCH 4/4] adds search_for_reconciliation to find reconcilation on xero --- app2.py | 126 +++++++++++++++++++++++++++++++++++++++---------- email-sally.md | 55 +++++++++++++++++++++ 2 files changed, 155 insertions(+), 26 deletions(-) diff --git a/app2.py b/app2.py index 31435b4..f405b18 100644 --- a/app2.py +++ b/app2.py @@ -41,7 +41,7 @@ """ -def find_or_create_bank_transaction(xero_tenant_id, api_client, target_date, target_amount): +def search_for_reconciliation(xero_tenant_id, api_client, target_date, target_amount): """ Search for bank transactions matching specific criteria using where filter and pagination. If no matching transaction is found, create a new one. @@ -49,48 +49,122 @@ def find_or_create_bank_transaction(xero_tenant_id, api_client, target_date, tar Returns a tuple of (result, error_message) """ accounting_api = AccountingApi(api_client) + # Search for existing bank transactions using where filter as Sally suggested + where_filter = f'Total=={target_amount} && Status!="DELETED"' + print(f"Searching for bank transactions with filter: {where_filter}") + + # Since it's an Invoice OR Receipt we are searching for, + # and since Invoice documents do not always have paid status. + # ``target_date`` could just be the date from which the + # invoice was created. + where_clause = ( + f'Total=={target_amount} && ' + f'Date>=DateTime({target_date.year},{target_date.month:02d},{target_date.day:02d})' + ) + + transactions = accounting_api.get_bank_transactions( + xero_tenant_id, + where=where_clause, + order="Date DESC", + if_modified_since=target_date + ) + invoices = accounting_api.get_invoices( + xero_tenant_id, + where=where_clause, + order="Date DESC", + ) + not_found = len(transactions.bank_transactions) == 0 and len(invoices.invoices) == 0 + not_reconciled = not_found + if not_reconciled: + print('Not reconciled') + else: + print('Reconciled') + return { + 'bank_transactions': transactions.bank_transactions, + 'invoices': invoices.invoices, + 'is_reconciled': not not_reconciled, + } + - # Hardcoded search criteria as requested - target_reference = "SMART Agency" +def find_or_create_bank_transaction(xero_tenant_id, api_client, target_date, target_amount): + """ + Search for bank transactions matching specific criteria using where filter and pagination. + If no matching transaction is found, create a new one. + Based on Sally's suggestion from Xero support. + Returns a tuple of (result, error_message) + """ + accounting_api = AccountingApi(api_client) + # Search for existing bank transactions using where filter as Sally suggested + where_filter = f'Total=={target_amount} && Status!="DELETED"' + print(f"Searching for bank transactions with filter: {where_filter}") + + # Since it's an Invoice OR Receipt we are searching for, + # and since Invoice documents do not always have paid status. + # ``target_date`` could just be the date from which the + # invoice was created. + where_clause = ( + f'Total=={target_amount} && ' + f'Date>=DateTime({target_date.year},{target_date.month:02d},{target_date.day:02d})' + ) + + transactions = accounting_api.get_bank_transactions( + xero_tenant_id, + where=where_clause, + order="Date DESC", + if_modified_since=target_date + ) + invoices = accounting_api.get_invoices( + xero_tenant_id, + where=where_clause, + order="Date DESC", + ) + not_found = len(transactions.bank_transactions) == 0 and len(invoices.invoices) == 0 + not_reconciled = not_found + if not_reconciled: + print('Not reconciled') + else: + print('Reconciled') + return { + 'bank_transactions': transactions.bank_transactions, + 'invoices': invoices.invoices, + 'is_reconciled': not not_reconciled, + } try: # Search for existing bank transactions using where filter as Sally suggested where_filter = f'Total=={target_amount} && Status!="DELETED"' print(f"Searching for bank transactions with filter: {where_filter}") - # import ipdb; ipdb.set_trace() + + # Since it's an Invoice OR Receipt we are searching for, + # and since Invoice documents do not always have paid status. + # ``target_date`` could just be the date from which the + # invoice was created. + where_clause = ( + f'Total=={target_amount} && ' + f'Date>=DateTime({target_date.year},{target_date.month:02d},{target_date.day:02d})' + ) + transactions = accounting_api.get_bank_transactions( xero_tenant_id, - where=( - f'Total=={target_amount} && ' - # f'Total=={target_amount} && Status!="DELETED" && ' - f'Date>=DateTime({target_date.year},{target_date.month:02d},{target_date.day:02d})' - ), + where=where_clause, order="Date DESC", if_modified_since=target_date ) invoices = accounting_api.get_invoices( xero_tenant_id, - where=( - f'Total=={target_amount} && ' - # f'Total=={target_amount} && Status!="DELETED" && ' - f'Date>=DateTime({target_date.year},{target_date.month:02d},{target_date.day:02d})' - ), + where=where_clause, order="Date DESC", ) not_found = len(transactions.bank_transactions) == 0 and len(invoices.invoices) == 0 - return transactions.bank_transactions, invoices.invoices - if not_found: - print( - "!!!!!! NO matching transaction found. " - f"target_date: {target_date}, target_mount {target_amount}" - ) + not_reconciled = not_found + if not_reconciled: + print('Not reconciled') else: - print( - "Found matching transaction." - f"target_date: {target_date}, target_mount {target_amount}" - ) - - return None, None + print('Reconciled') + return { + 'bank_transactions': transactions.bank_transactions, + 'invoices': invoices.invoices, + } print(f"Found {len(transactions.bank_transactions)} transactions matching amount criteria") diff --git a/email-sally.md b/email-sally.md index e69de29..b007ecc 100644 --- a/email-sally.md +++ b/email-sally.md @@ -0,0 +1,55 @@ + + +What you suggested doesn't seem to work: + +1) +I'm using the Demo Company, it has some unreconciled transactions. + +e.g. +Jakaranda Maple Systems, 2000GBP, 30 May 2025, + + + +2) +I query for this one using the Python library: + +``` +transactions = accounting_api.get_bank_transactions( + xero_tenant_id, + where=f'Total==2000.00 && Status!="DELETED"', + order="Date DESC" +) +invoices = accounting_api.get_invoices( + xero_tenant_id, + where=f'Total==2000.00 && Status!="DELETED"', + order="Date DESC" +) +``` + +3) +The result is + +No transactions: + +``` +{'bank_transactions': [], 'pagination': None, 'warnings': None} +``` + +1 invoice returned but it's NOT the correct unreconciled payment: + +ipdb> invoices.invoices[0].to_dict()['contact']['name'] +'SMART Agency' +ipdb> invoices.invoices[0].to_dict()['total'] +Decimal('2000.00') +``` + +Despite them showing on the UI. + + +---- + +So searching for that unreconciled payment via `get_bank_transactions` or `get_invoices` does not return what we're looking for. + + + +