Skip to content

chore: upgrade plaid-python from 7.2.1 to 39.2.0#7

Draft
ravibharathi656 wants to merge 3 commits into
developfrom
plaid-sdk-upgrade
Draft

chore: upgrade plaid-python from 7.2.1 to 39.2.0#7
ravibharathi656 wants to merge 3 commits into
developfrom
plaid-sdk-upgrade

Conversation

@ravibharathi656

@ravibharathi656 ravibharathi656 commented May 28, 2026

Copy link
Copy Markdown
Member

Issue:

The current Plaid integration uses plaid-python v7.2.1 which relies on the deprecated plaid.Client initialization pattern and transactions/get endpoint. Plaid has since moved to a new SDK structure (plaid.Configuration / ApiClient) and recommends transactions/sync for incremental transaction fetching.

@ravibharathi656 ravibharathi656 marked this pull request as draft May 28, 2026 12:34
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR upgrades the Plaid Python SDK from v7.2.1 to v39.2.0 and refactors transaction synchronization from date-range polling to cursor-based incremental sync. The Bank Account DocType gains a plaid_sync_cursor field to persist sync position across runs. PlaidSettings adds transactions_days_requested configuration with 90–730 day validation. PlaidConnector is completely refactored to use typed request/response models from the v39 API, with environment/country/language constants and a new _get_client() builder. Transaction fetching now uses TransactionsSyncRequest in a loop to accumulate added/modified/removed items. PlaidSettings integration methods are updated to instantiate PlaidConnector, handle cursor-based updates, and enqueue jobs with long queue and deterministic naming.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title accurately describes the main change: upgrading the plaid-python dependency from v7.2.1 to v39.2.0, which is the core change reflected in pyproject.toml.
Description check ✅ Passed The description is directly related to the changeset, explaining the motivation for the upgrade (deprecated plaid.Client pattern, new SDK structure with plaid.Configuration, and transactions/sync recommendation).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch plaid-sdk-upgrade

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py (1)

175-177: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Generic exception silently returns None, causing downstream AttributeError.

The generic except Exception block logs the error but neither re-raises nor returns a value. The caller (sync_transactions) expects a dict and will fail when accessing updates.get("added") on None.

🐛 Proposed fix — re-raise or return empty result
 	except Exception:
 		frappe.log_error("Plaid: Transactions sync error")
+		raise

Or if you prefer graceful degradation:

 	except Exception:
 		frappe.log_error("Plaid: Transactions sync error")
+		frappe.throw(_("Cannot sync transactions. Check Error Log for more information"))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py`
around lines 175 - 177, The except Exception block that logs "Plaid:
Transactions sync error" currently swallows the error and returns None, causing
sync_transactions to crash when it calls updates.get("added"); update that
exception handler in plaid_connector.py to either re-raise the caught exception
after logging or return a safe empty-structure (e.g., a dict with keys like
"added", "modified", "removed" set to empty lists) so callers like
sync_transactions can safely call updates.get(...); keep the logging, but ensure
you either raise the error (reraise the caught Exception) or return the explicit
empty dict to preserve downstream behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py`:
- Around line 103-109: In get_access_token, after detecting public_token is None
(currently only logging via frappe.log_error), stop execution instead of
proceeding; either raise a clear exception (e.g., ValueError or
frappe.ValidationError) or return None immediately so you don't call
ItemPublicTokenExchangeRequest(public_token=public_token) and
self.client.item_public_token_exchange with a None token; update the function
(get_access_token) to perform the early exit and ensure callers handle the
raised error/None accordingly.
- Around line 162-164: The code is calling .get() on a typed
TransactionsSyncResponse (which is a Pydantic model) and will raise
AttributeError; change the loop to read the model attributes directly (e.g., use
response.added, response.modified, response.removed) and default to an empty
list when any of them is None (e.g., response.added or []), and set
updates["next_cursor"] from response.next_cursor (or response.next_cursor or
None) instead of response.get(...); update references around the for key in
("added","modified","removed") loop and the updates["next_cursor"] assignment
and keep using the existing _to_dict(transaction) mapping.
- Around line 146-148: The code creates a TransactionsSyncRequestOptions
instance but sets the wrong attribute (options.account_id) so account filtering
is not applied; change it to set options.account_ids = [account_id] (or pass
account_ids in the constructor) when account_id is present, and ensure any
cursor saved/used for TransactionsSyncRequest/TransactionsSyncResponse handling
corresponds to the filtered account stream (so stored cursor logic in the
surrounding sync method uses the same account_ids filter).

In `@erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py`:
- Around line 218-237: In sync_transactions, after getting updates =
get_transactions(...) and before setting the plaid_sync_cursor, apply
updates["modified"] and updates["removed"] to existing Bank Transaction records:
for each entry in updates["modified"] locate the matching Bank Transaction (by
transaction id / other Plaid key), update the changed fields (using the same
mapping/new_bank_transaction conventions) and save; for each entry in
updates["removed"] locate the Bank Transaction and either delete or mark it
removed/cancelled per existing domain logic; only after persisting those
modifications/deletions write updates["next_cursor"] to Bank Account; keep
existing handling for updates["added"] via new_bank_transaction and retain the
info logs for counts.

---

Outside diff comments:
In `@erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py`:
- Around line 175-177: The except Exception block that logs "Plaid: Transactions
sync error" currently swallows the error and returns None, causing
sync_transactions to crash when it calls updates.get("added"); update that
exception handler in plaid_connector.py to either re-raise the caught exception
after logging or return a safe empty-structure (e.g., a dict with keys like
"added", "modified", "removed" set to empty lists) so callers like
sync_transactions can safely call updates.get(...); keep the logging, but ensure
you either raise the error (reraise the caught Exception) or return the explicit
empty dict to preserve downstream behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 50730cc3-68ec-4807-922d-1c7c6058b9ad

📥 Commits

Reviewing files that changed from the base of the PR and between fd7a97f and 80b2183.

📒 Files selected for processing (7)
  • erpnext/accounts/doctype/bank_account/bank_account.json
  • erpnext/accounts/doctype/bank_account/bank_account.py
  • erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py
  • erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.js
  • erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.json
  • erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py
  • pyproject.toml

Comment on lines 103 to +109
def get_access_token(self, public_token):
if public_token is None:
frappe.log_error("Plaid: Public token is missing")
response = self.client.Item.public_token.exchange(public_token)
access_token = response["access_token"]
return access_token

request = ItemPublicTokenExchangeRequest(public_token=public_token)
response = self.client.item_public_token_exchange(request)
return response.access_token

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing early exit after null check causes request with None token.

The null check logs an error but does not return or raise, so the code proceeds to call the API with public_token=None, which will fail.

🐛 Proposed fix
 def get_access_token(self, public_token):
 	if public_token is None:
 		frappe.log_error("Plaid: Public token is missing")
+		frappe.throw(_("Public token is missing"))

 	request = ItemPublicTokenExchangeRequest(public_token=public_token)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py`
around lines 103 - 109, In get_access_token, after detecting public_token is
None (currently only logging via frappe.log_error), stop execution instead of
proceeding; either raise a clear exception (e.g., ValueError or
frappe.ValidationError) or return None immediately so you don't call
ItemPublicTokenExchangeRequest(public_token=public_token) and
self.client.item_public_token_exchange with a None token; update the function
(get_access_token) to perform the early exit and ensure callers handle the
raised error/None accordingly.

Comment on lines +146 to +148
options = TransactionsSyncRequestOptions()
if account_id:
kwargs.update(dict(account_ids=[account_id]))
options.account_id = account_id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Plaid transactions/sync API filter by account_id options

💡 Result:

The Plaid /transactions/sync API allows for optional filtering by account_id [1]. You can include an options object with an account_ids field in your request to retrieve transaction updates for one or more specific accounts associated with a given Item [1]. Key considerations when filtering by account_id include: Separate Incremental Streams: When you provide an account_id in the request, Plaid creates a separate incremental update stream for that specific account [1]. Cursor Management: Because the stream is separate, it requires its own unique cursor [1]. If you query multiple accounts this way, you must maintain a distinct cursor for each account_id [1]. Switching Strategies: If you decide to start filtering by account_id after previously requesting all accounts (or vice versa), you must start fresh with a null cursor for the new configuration [1]. Do not reuse previously saved cursors across different filtering configurations, as this can lead to pagination errors or incomplete data [1]. Error Handling: The API will return an error if you provide an account_id that is not associated with the requested Item [1]. Default Behavior: If the account_ids option is omitted, the API returns updates for all accounts associated with the Item and maintains a single cursor for the entire Item [1]. For developers who require filtering by date ranges, the /transactions/sync endpoint does not support this natively [2]. If your application requires date-based filtering, you should fetch the data using the sync endpoint and then implement the filtering logic on your own server or database after the data has been retrieved [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py"

echo "== File exists =="
ls -l "$FILE"

echo "== Relevant lines around 120-210 =="
nl -ba "$FILE" | sed -n '110,220p'

echo "== Search for get_transactions and Plaid sync call =="
rg -n "def get_transactions|get\\(\"added\"\\)|added\\b|TransactionsSyncRequestOptions|transactions/sync|transactions_sync" "$FILE"

echo "== Search for how options is passed =="
rg -n "options\\s*=" "$FILE"
rg -n "transactions.*sync|sync" "$FILE"

Repository: aerele/erpnext

Length of output: 294


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py"

echo "== Relevant lines around 120-190 (no nl available) =="
python3 - <<'PY'
import itertools
path = "erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py"
start, end = 120, 190
with open(path, "r", encoding="utf-8") as f:
    for i, line in enumerate(f, start=1):
        if start <= i <= end:
            print(f"{i:4d}: {line.rstrip()}")
        if i > end:
            break
PY

echo "== Search within the file =="
rg -n "def get_transactions|TransactionsSyncRequestOptions|transactions/sync|get\\(\"added\"\\)|\\badded\\b|has_more|next_cursor|options\\." "$FILE" || true

echo "== Show imports =="
python3 - <<'PY'
path = "erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py"
with open(path, "r", encoding="utf-8") as f:
    for i, line in enumerate(f, start=1):
        if i <= 70:
            print(f"{i:4d}: {line.rstrip()}")
        else:
            break
PY

Repository: aerele/erpnext

Length of output: 4818


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import inspect

try:
    import plaid
    from plaid.model.transactions_sync_request_options import TransactionsSyncRequestOptions
    from plaid.model.transactions_sync_request import TransactionsSyncRequest
except Exception as e:
    print("IMPORT_ERROR", e)
    raise

print("PLAID_VERSION_ATTRS", [a for a in dir(plaid) if "version" in a.lower() or a.lower()=="__version__"])
print("TransactionsSyncRequestOptions class:", TransactionsSyncRequestOptions)

# show datamodel attrs / constructor signature
print("init sig:", inspect.signature(TransactionsSyncRequestOptions.__init__))
print("dir sample attrs:", [x for x in dir(TransactionsSyncRequestOptions) if not x.startswith("_")][:50])

# Try instantiation and attribute setting
opt = TransactionsSyncRequestOptions()
# Check which attributes exist (none set yet)
for candidate in ["account_id","account_ids","updated_since","include_personal_details","institution_id"]:
    has = hasattr(opt, candidate)
    print("HAS_ATTR", candidate, has)

# If sdk uses openapi-style swagger_types / attribute map, print it
for name in ["openapi_types","swagger_types","attribute_map","additional_properties_type"]:
    if hasattr(TransactionsSyncRequestOptions, name):
        val = getattr(TransactionsSyncRequestOptions, name)
        print("MODEL_HAS", name, type(val))
        print(val)

# Now inspect transactions_sync response model if accessible
try:
    resp_type = None
    # Look for a class in plaid.model that contains 'TransactionsSync' besides request/options
    import pkgutil, plaid.model
    candidates = []
    for m in pkgutil.iter_modules(plaid.model.__path__):
        if "transactions_sync" in m.name:
            candidates.append(m.name)
    print("transactions_sync model modules:", candidates[:50])
except Exception as e:
    print("RESP_MODEL_INSPECTION_ERROR", e)
PY

Repository: aerele/erpnext

Length of output: 231


🌐 Web query:

Plaid Python SDK TransactionsSyncRequestOptions account_id account_ids

💡 Result:

In the Plaid Python SDK, the TransactionsSyncRequest object—used with the client.transactions_sync method—does not utilize an options object or an account_ids parameter [1][2][3]. The TransactionsSync endpoint is designed to provide incremental updates using a cursor instead of parameters like date ranges or account filters [2][4]. If you need to retrieve transactions for specific accounts, you must perform this filtering on your own application side after receiving the transaction data from the transactions_sync response [2]. This differs from the older TransactionsGet endpoint, which supported a TransactionsGetRequestOptions object containing an account_ids field [5][6][3]. Key distinctions to keep in mind: - TransactionsSyncRequest: Accepts access_token and cursor (no options object) [1][3]. - TransactionsGetRequest: Accepts access_token, start_date, end_date, and an optional options object (which supports account_ids) [5][3]. If you provide an account_id when calling the transactions_sync endpoint (if using a lower-level manual implementation), be aware that Plaid creates a separate incremental update stream for that specific account, meaning you must maintain a separate cursor for every account_id you filter by [1]. However, the standard TransactionsSyncRequest model in the official SDK does not expose this parameter [3].

Citations:


🌐 Web query:

Plaid Python SDK transactions_sync response object supports get has_more next_cursor

💡 Result:

Yes, the Plaid Python SDK's transactions_sync response object supports the has_more and next_cursor fields [1][2]. When you call the transactions_sync endpoint, the returned response object (which is an instance of the TransactionsSyncResponse class) includes these fields to handle pagination [1][2]: 1. has_more (boolean): Indicates whether there are additional transaction updates available beyond the current page [3][4]. 2. next_cursor (string): Provides the cursor value required to fetch the next page of updates [3][4]. To retrieve all available transaction data, you should implement a loop that checks the has_more flag and continues to call transactions_sync while passing the next_cursor value as the cursor in your request [1][5]. Example of the pagination pattern in Python: import plaid from plaid.model.transactions_sync_request import TransactionsSyncRequest # Initial request request = TransactionsSyncRequest(access_token=access_token) response = client.transactions_sync(request) transactions = response['added'] # Paginate if more data exists while (response['has_more']): request = TransactionsSyncRequest( access_token=access_token, cursor=response['next_cursor']) response = client.transactions_sync(request) transactions += response['added'] Once has_more is false, you should save the latest next_cursor to persist for future sync calls [3][5][4].

Citations:


🌐 Web query:

plaid-python TransactionsSyncRequestOptions account_ids account_id field name

💡 Result:

In plaid-python, TransactionsSyncRequestOptions uses the keyword name account_ids (a list of strings) to filter the /transactions/sync results to specific accounts.[1] So you can pass: - account_ids= to Transactions.get(..., account_ids=...) - or, if using the lower-level _options dict, include the key 'account_ids'.[1] There is also an account_id parameter on the Plaid Transactions API side (single-account filtering), but in plaid-python the corresponding option exposed on TransactionsSyncRequestOptions is account_ids (plural).[2] Evidence: - plaid-python docs show client.Transactions.get(..., account_ids=account_ids) and that account_ids is the option name in the underlying request options.[1] - Plaid Transactions API docs describe filtering by account_id for /transactions/sync and note cursor behavior per account_id (single account filtering).[2]

Citations:


🌐 Web query:

github plaid-python TransactionsSyncRequestOptions class transactions_sync_request_options.py

💡 Result:

The TransactionsSyncRequestOptions class is part of the Plaid Python client library, typically located within the plaid.model package as transactions_sync_request_options.py [1][2]. This class is used to provide optional configuration settings when calling the /transactions/sync endpoint [3]. In the plaid-python library, the TransactionsSyncRequestOptions object allows you to customize the behavior of the transactions sync request [3]. Common fields available in this options class include: - include_personal_finance_category: A boolean value to determine whether to include personal finance categories in the response [4]. - include_original_description: A boolean value to include the raw, unparsed transaction description from the financial institution [4]. To use this class, you generally instantiate it and pass it to the options parameter of the TransactionsSyncRequest, which is then sent via the client.transactions_sync method [1][3]. Example usage (conceptual): from plaid.model.transactions_sync_request import TransactionsSyncRequest from plaid.model.transactions_sync_request_options import TransactionsSyncRequestOptions options = TransactionsSyncRequestOptions( include_personal_finance_category=True) request = TransactionsSyncRequest( access_token=access_token, cursor=cursor, options=options) response = client.transactions_sync(request) Note that while the library provides this options object for flexibility, it is recommended to check the official Plaid API documentation or the specific version of the plaid-python library you are using, as generated models can evolve [2][5]. You can view the library source code directly in the official GitHub repository at github.com/plaid/plaid-python [1].

Citations:


🌐 Web query:

github plaid-python TransactionsSyncResponse supports get() dictionary access has_more next_cursor added

💡 Result:

In the plaid-python library, the TransactionsSyncResponse object is designed to be accessed using dictionary-style syntax (e.g., response['added'], response['has_more'], response['next_cursor']) [1][2]. While these objects are Pydantic or OpenApi-generated models and not native Python dictionaries, the plaid-python client library supports this access method [2]. If you need to work with the response as a standard dictionary, you can use the built-in.to_dict method available on the model, for example: response.to_dict [2][3]. The has_more and next_cursor fields are essential for paginating through transaction updates [1][4]. The standard pattern for retrieving all available transactions is to use a while loop that checks the has_more flag and updates the cursor using next_cursor [1][2][5]: while (response['has_more']): request = TransactionsSyncRequest( access_token=access_token, cursor=response['next_cursor']) response = client.transactions_sync(request) transactions += response['added'] It is important to retrieve all available pages before persisting the data to your database [6][4]. Ensure your library version is at least 9.4.0 to support the /transactions/sync endpoint [4].

Citations:


🌐 Web query:

plaid-python TransactionsSyncResponse .get('added') support

💡 Result:

Yes, the plaid-python library fully supports the added field within the TransactionsSyncResponse object returned by the /transactions/sync endpoint [1][2]. When you call the transactions_sync method using the Plaid API client, the returned TransactionsSyncResponse object contains an added field, which is a list of new transactions [3][4][2]. You can access these transactions directly using response.added [4][2]. Key implementation details include: 1. Data Access: Because the Plaid Python client uses model objects to encapsulate API responses, you can access the added data via response.added [2]. If you need to convert the response to a standard Python dictionary for easier manipulation, you can use the built-in.to_dict method [2][5]: response_dict = response.to_dict transactions = response_dict.get('added', []) 2. Pagination: The transactions/sync endpoint is paginated [6]. You must check the has_more flag in the response and use the next_cursor to perform subsequent calls until has_more is false [7]. 3. Maintaining State: To keep your local data consistent with Plaid, you should process all fields provided in the response: added (new transactions), modified (updated transactions), and removed (deleted or posted pending transactions) [6][8]. Example usage from the official plaid-python documentation [2]: request = TransactionsSyncRequest(access_token=access_token) response = client.transactions_sync(request) transactions = response.added while response.has_more: request = TransactionsSyncRequest( access_token=access_token, cursor=response.next_cursor) response = client.transactions_sync(request) transactions += response.added

Citations:


🌐 Web query:

plaid-python generated model get method response.get

💡 Result:

In the plaid-python library, API methods return a generated response object, not a plain Python dictionary. To access the data returned by an endpoint, you should generally use the to_dict() method on the response object [1]. While the response object behaves like a dictionary in some contexts (e.g., you may see code examples accessing fields directly like response['added'] [2][3]), the official and recommended way to ensure consistent access to the underlying data structure is to call .to_dict() on the response instance [1]. For example: python # Recommended approach response = client.transactions_sync(request) data = response.to_dict() transactions = data['added'] If you attempt to use a .get() method directly on the response object (e.g., response.get('key')), it may not work as expected because the response is a model object, not a standard dict. By converting it to a dictionary first using .to_dict(), you gain access to the standard Python dictionary methods, including .get(), to safely retrieve values [1][4].

Citations:


Fix account filtering for /transactions/sync: use TransactionsSyncRequestOptions.account_ids (plural).

Plaid supports per-account filtering for /transactions/sync, but in the plaid-python SDK it’s configured via TransactionsSyncRequestOptions.account_ids (a list). Assigning options.account_id (singular) won’t apply the filter (or will be ignored), so you’ll likely sync all accounts instead of the requested one. Use options.account_ids = [account_id] (or the constructor) and ensure the stored cursor matches the filtered account stream.

		options = TransactionsSyncRequestOptions()
		if account_id:
			options.account_id = account_id
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py`
around lines 146 - 148, The code creates a TransactionsSyncRequestOptions
instance but sets the wrong attribute (options.account_id) so account filtering
is not applied; change it to set options.account_ids = [account_id] (or pass
account_ids in the constructor) when account_id is present, and ensure any
cursor saved/used for TransactionsSyncRequest/TransactionsSyncResponse handling
corresponds to the filtered account stream (so stored cursor logic in the
surrounding sync method uses the same account_ids filter).

Comment on lines +162 to +164
for key in ("added", "modified", "removed"):
updates[key].extend(self._to_dict(transaction) for transaction in response.get(key, []))
updates["next_cursor"] = response.get("next_cursor")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Typed response objects do not have .get() method — will raise AttributeError.

The TransactionsSyncResponse is a typed Pydantic model, not a dict. Using response.get("added", []) will fail at runtime. Access attributes directly instead.

🐛 Proposed fix
-			for key in ("added", "modified", "removed"):
-				updates[key].extend(self._to_dict(transaction) for transaction in response.get(key, []))
-			updates["next_cursor"] = response.get("next_cursor")
+			updates["added"].extend(self._to_dict(t) for t in response.added)
+			updates["modified"].extend(self._to_dict(t) for t in response.modified)
+			updates["removed"].extend(self._to_dict(t) for t in response.removed)
+			updates["next_cursor"] = response.next_cursor
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for key in ("added", "modified", "removed"):
updates[key].extend(self._to_dict(transaction) for transaction in response.get(key, []))
updates["next_cursor"] = response.get("next_cursor")
updates["added"].extend(self._to_dict(t) for t in response.added)
updates["modified"].extend(self._to_dict(t) for t in response.modified)
updates["removed"].extend(self._to_dict(t) for t in response.removed)
updates["next_cursor"] = response.next_cursor
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@erpnext/erpnext_integrations/doctype/plaid_settings/plaid_connector.py`
around lines 162 - 164, The code is calling .get() on a typed
TransactionsSyncResponse (which is a Pydantic model) and will raise
AttributeError; change the loop to read the model attributes directly (e.g., use
response.added, response.modified, response.removed) and default to an empty
list when any of them is None (e.g., response.added or []), and set
updates["next_cursor"] from response.next_cursor (or response.next_cursor or
None) instead of response.get(...); update references around the for key in
("added","modified","removed") loop and the updates["next_cursor"] assignment
and keep using the existing _to_dict(transaction) mapping.

Comment on lines +218 to +237
updates = get_transactions(bank=bank, bank_account=bank_account)
transactions = updates.get("added", [])

result = []
if transactions:
for transaction in reversed(transactions):
result += new_bank_transaction(transaction)

if updates.get("next_cursor"):
frappe.db.set_value("Bank Account", bank_account, "plaid_sync_cursor", updates["next_cursor"])

if updates.get("modified"):
frappe.logger().info(
f"Plaid received {len(updates['modified'])} modified transactions for '{bank_account}'."
)

if updates.get("removed"):
frappe.logger().info(
f"Plaid received {len(updates['removed'])} removed transactions for '{bank_account}'."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

In Plaid transactions/sync, what is the recommended integration behavior for added, modified, and removedupdates, and shouldnext_cursor be advanced only after all three update sets are successfully persisted?

💡 Result:

In Plaid's /transactions/sync integration, the recommended behavior is to treat all updates returned in the added, modified, and removed arrays as essential patches to your local database [1][2]. These updates must be applied together to maintain an accurate, consistent mirror of the underlying account data [1][3]. Regarding persistence and cursors: 1. Handling Pagination: When a response indicates has_more is true, you must continue to call /transactions/sync using the next_cursor from the current response to retrieve all available pages [2][4]. It is crucial to collect all updates across all pages before persisting them to your database [2][4]. 2. Advancing the Cursor: You should store and advance the next_cursor only after you have successfully processed the entire set of updates [4][5]. Once a pagination sequence is complete (i.e., has_more is false), you should persist the final next_cursor [6][4]. This cursor will serve as the starting point for your next sync request, ensuring you do not miss any data or create gaps in your transaction history [2][7]. 3. Handling Errors: If you encounter an error like TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION while paginating, you must abandon the current sequence and restart the entire pagination loop from the cursor you held at the beginning of that specific sync session [8][7][5]. By applying these patches in order and updating your cursor only upon completion, you maintain data integrity and prevent issues like duplicated or inaccurate transaction records [9][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py"
# Show the full surrounding function so we can see if modified/removed are handled elsewhere.
python3 - <<'PY'
import itertools,sys,os
path="erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py"
start=180
end=280
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
for i in range(start-1,end):
    if 0<=i<len(lines):
        print(f"{i+1:4d}: {lines[i].rstrip()}")
PY

Repository: aerele/erpnext

Length of output: 3909


🌐 Web query:

Plaid /transactions/sync added modified removed arrays must be applied to your database recommended behavior cursor next_cursor advance only after processing all updates

💡 Result:

Recommended behavior for Plaid /transactions/sync (and the cursor pagination pattern) is: 1) Apply ALL update types (added, modified, removed) to your database for each sync response to keep your stored account data consistent. Plaid explicitly recommends applying all modified, added, and removed updates surfaced through /transactions/sync “in order to maintain consistency with the underlying account data.” It also notes that pending→posted transitions are represented as a removed pending transaction id and a new posted transaction in added (they may not appear on the same page), but they should happen within the same overall update. [1] 2) When there are multiple pages (has_more = true), process every page before advancing your persisted cursor. Plaid’s API docs show concatenating added/modified/removed across pages and then updating the cursor to next_cursor inside the pagination loop, continuing until has_more is false. [2] 3) Handle next_cursor persistence only after fully processing the full “has_more=false” run. Plaid describes that you should “persist cursor and updated data” after successfully pulling all currently available pages (i.e., after the loop ends when has_more is false). [3] 4) If you receive TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION during pagination, restart the entire pagination loop from the original cursor of the first page (not just retry the failed request). Plaid calls out that behavior explicitly for paginated /transactions/sync retrieval. [2] [4] In short: store/process the added/modified/removed arrays as a single logical unit per overall sync update, paginate through all pages, and only persist the cursor after you’ve processed all pages (has_more=false). [1][2][3]

Citations:


🌐 Web query:

Plaid documentation transactions/sync removed transactions integration handling deletion when removed present

💡 Result:

When using Plaid /transactions/sync, removed transactions are handled via the sync response’s removed array (not by relying on the TRANSACTIONS_REMOVED webhook for business logic). 1) What “removed” means in Plaid - Plaid fires a TRANSACTIONS_REMOVED webhook when transactions have been removed; it commonly happens when a pending transaction becomes posted, where the pending transaction is removed and a new posted transaction is added. [1] - Plaid also removes transactions when they’re canceled, or when bank-side details change so extensively that Plaid can no longer match old vs new as the same transaction (“transaction churn”), in which case the old transaction is deleted and a new one is added. [1] 2) For /transactions/sync: where deletions show up - The /transactions/sync migration guide states that you should not use HISTORICAL_UPDATE / DEFAULT_UPDATE / INITIAL_UPDATE / TRANSACTIONS_REMOVED webhooks for /transactions/sync business logic. Instead, listen for SYNC_UPDATES_AVAILABLE and call /transactions/sync. [2] - The /transactions/sync response includes patches you should apply to your database: added, removed, and modified arrays. For deletions, you apply the removed array to delete (or otherwise mark removed) the corresponding transactions in your system. [2] 3) Cursor-sync behavior implies “reconciliation” of removals - The /transactions/sync migration guide describes the design: updates returned by /transactions/sync include new, modified, and removed transactions since your previous request, and you can patch them into your database without doing a complex reconciliation. [2] Practical integration handling pattern - Listen for SYNC_UPDATES_AVAILABLE; when received, call /transactions/sync. - Persist the cursor (next_cursor) returned by /transactions/sync. - Apply response.removed: delete the matching transaction IDs from your datastore (or mark them removed) so your UI doesn’t show transactions that Plaid no longer has. [2] Optional/legacy note (if you also listen to webhooks) - Even though TRANSACTIONS_REMOVED can still be sent for backwards compatibility, the official sync migration guidance says it is not required for /transactions/sync business logic. [2]

Citations:


Apply modified and removed transaction deltas in sync_transactions before updating plaid_sync_cursor.

sync_transactions only creates ERPNext transactions from updates["added"]; updates["modified"] and updates["removed"] are merely logged, so stale/canceled/deleted (or pending→posted) transactions can remain incorrect. Apply modified (update fields) and removed (delete/mark removed) to the matching Bank Transaction records, then persist updates["next_cursor"] once those changes are persisted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@erpnext/erpnext_integrations/doctype/plaid_settings/plaid_settings.py` around
lines 218 - 237, In sync_transactions, after getting updates =
get_transactions(...) and before setting the plaid_sync_cursor, apply
updates["modified"] and updates["removed"] to existing Bank Transaction records:
for each entry in updates["modified"] locate the matching Bank Transaction (by
transaction id / other Plaid key), update the changed fields (using the same
mapping/new_bank_transaction conventions) and save; for each entry in
updates["removed"] locate the Bank Transaction and either delete or mark it
removed/cancelled per existing domain logic; only after persisting those
modifications/deletions write updates["next_cursor"] to Bank Account; keep
existing handling for updates["added"] via new_bank_transaction and retain the
info logs for counts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant