Compare commits

...

6 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
cbb458af8e Merge branch 'master' into claude/fix-simplefin-batch-sync-O8LcD 2026-03-10 22:17:05 +00:00
Claude
1694bf6207 [AI] Fix SimpleFin batch sync to emit ACCOUNT_MISSING for empty payloads
In the batch sync path, if a per-account download payload is an empty
object or is missing the transactions array, processBankSyncDownload
would crash and the error would be caught as INTERNAL_ERROR. Now we
check for these cases explicitly and emit ACCOUNT_MISSING instead,
while still allowing entries with error_code to propagate their
specific error.

https://claude.ai/code/session_01XbHgxxrXYR3UTyW6VmYj47
2026-03-09 21:56:43 +00:00
Matiss Janis Aboltins
fbdf02deca Merge branch 'master' into claude/fix-simplefin-batch-sync-O8LcD 2026-03-09 20:52:47 +00:00
Claude
1f2f8c5f10 [AI] Fix SimpleFIN batch sync error_code TypeError
Fix "Cannot read properties of undefined (reading 'error_code')" that
occurs during SimpleFIN batch sync by:

1. Adding null check for downloadSimpleFinTransactions result in
   simpleFinBatchSync (sync.ts) - the function can return undefined
   when user token is missing

2. Adding .catch() handler on individual processBankSyncDownload
   promises so a single account failure doesn't crash the entire
   batch via Promise.all rejection

3. Using optional chaining on syncResponse.res?.error_code in app.ts
   and handling the case where res is undefined with proper error
   reporting

https://claude.ai/code/session_01XbHgxxrXYR3UTyW6VmYj47
2026-03-09 20:50:48 +00:00
github-actions[bot]
3c32429dfb Add release notes for PR #7152 2026-03-07 20:39:54 +00:00
Claude
938a7c11f9 [AI] Fix SimpleFin batch sync crash when accounts are missing from response
When SimpleFin doesn't return data for all requested accounts during
batch sync, the code crashed with a TypeError accessing properties on
undefined, resulting in a generic "internal error" message for users.

This fix:
- Adds a guard in simpleFinBatchSync for missing account data, returning
  an ACCOUNT_MISSING error instead of crashing
- Propagates error entries from the SimpleFin response's errors map for
  accounts that have no data entry
- Adds a user-friendly ACCOUNT_MISSING error message in the UI suggesting
  to unlink and relink the account
- Adds test cases covering both scenarios

https://claude.ai/code/session_01XbHgxxrXYR3UTyW6VmYj47
2026-03-06 19:49:16 +00:00
5 changed files with 227 additions and 16 deletions

View File

@@ -68,6 +68,11 @@ function useErrorMessage() {
</Trans>
);
case 'ACCOUNT_MISSING':
return t(
'This account was not found in SimpleFIN. Try unlinking and relinking the account.',
);
default:
}

View File

@@ -1069,7 +1069,7 @@ async function simpleFinBatchSync({
const matchedTransactions: Array<TransactionEntity['id']> = [];
const updatedAccounts: Array<AccountEntity['id']> = [];
if (syncResponse.res.error_code) {
if (syncResponse.res?.error_code) {
errors.push(
handleSyncError(
{
@@ -1081,7 +1081,7 @@ async function simpleFinBatchSync({
account,
),
);
} else {
} else if (syncResponse.res) {
const syncResponseData = await handleSyncResponse(
syncResponse.res,
account,
@@ -1090,6 +1090,15 @@ async function simpleFinBatchSync({
newTransactions.push(...syncResponseData.newTransactions);
matchedTransactions.push(...syncResponseData.matchedTransactions);
updatedAccounts.push(...syncResponseData.updatedAccounts);
} else {
errors.push(
handleSyncError(
new Error(
'Failed syncing account "' + account.name + '": empty response',
),
account,
),
);
}
retVal.push({

View File

@@ -1,13 +1,19 @@
// @ts-strict-ignore
import * as asyncStorage from '../../platform/server/asyncStorage';
import * as monthUtils from '../../shared/months';
import type { SyncedPrefs } from '../../types/prefs';
import * as db from '../db';
import { loadMappings } from '../db/mappings';
import { post } from '../post';
import { getServer } from '../server-config';
import { handlers } from '../tests/mockSyncServer';
import { insertRule, loadRules } from '../transactions/transaction-rules';
import { addTransactions, reconcileTransactions } from './sync';
import {
addTransactions,
reconcileTransactions,
simpleFinBatchSync,
} from './sync';
vi.mock('../../shared/months', async () => ({
...(await vi.importActual('../../shared/months')),
@@ -546,3 +552,128 @@ describe('Account sync', () => {
},
);
});
describe('SimpleFin batch sync', () => {
function mockSimpleFinTransactions(response) {
vi.mocked(asyncStorage.getItem).mockResolvedValue('test-token');
handlers['/simplefin/transactions'] = () => response;
}
afterEach(() => {
delete handlers['/simplefin/transactions'];
});
test('returns ACCOUNT_MISSING error when an account is not in the response', async () => {
const presentAccountId = 'sf-account-1';
const missingAccountId = 'sf-account-2';
// Mock SimpleFin response that only returns data for one of two accounts
mockSimpleFinTransactions({
[presentAccountId]: {
transactions: { all: [], booked: [], pending: [] },
balances: [],
startingBalance: 0,
},
errors: {},
});
// Insert two accounts linked to SimpleFin
const acct1Id = await db.insertAccount({
id: 'acct-1',
account_id: presentAccountId,
name: 'Account 1',
account_sync_source: 'simpleFin',
});
await db.insertPayee({
id: 'transfer-' + acct1Id,
name: '',
transfer_acct: acct1Id,
});
const acct2Id = await db.insertAccount({
id: 'acct-2',
account_id: missingAccountId,
name: 'Account 2',
account_sync_source: 'simpleFin',
});
await db.insertPayee({
id: 'transfer-' + acct2Id,
name: '',
transfer_acct: acct2Id,
});
const results = await simpleFinBatchSync([
{ id: 'acct-1', account_id: presentAccountId },
{ id: 'acct-2', account_id: missingAccountId },
]);
// The present account should succeed (no error_code)
const presentResult = results.find(r => r.accountId === 'acct-1');
expect(presentResult).toBeDefined();
expect(presentResult.res.error_code).toBeUndefined();
// The missing account should have ACCOUNT_MISSING error
const missingResult = results.find(r => r.accountId === 'acct-2');
expect(missingResult).toBeDefined();
expect(missingResult.res.error_code).toBe('ACCOUNT_MISSING');
expect(missingResult.res.error_type).toBe('ACCOUNT_MISSING');
});
test('propagates ACCOUNT_MISSING error from SimpleFin response errors', async () => {
const presentAccountId = 'sf-account-1';
const missingAccountId = 'sf-account-2';
// Mock SimpleFin response with error entry for missing account
mockSimpleFinTransactions({
[presentAccountId]: {
transactions: { all: [], booked: [], pending: [] },
balances: [],
startingBalance: 0,
},
errors: {
[missingAccountId]: [
{
error_type: 'ACCOUNT_MISSING',
error_code: 'ACCOUNT_MISSING',
reason: 'Account not found',
},
],
},
});
const acct1Id = await db.insertAccount({
id: 'acct-1',
account_id: presentAccountId,
name: 'Account 1',
account_sync_source: 'simpleFin',
});
await db.insertPayee({
id: 'transfer-' + acct1Id,
name: '',
transfer_acct: acct1Id,
});
const acct2Id = await db.insertAccount({
id: 'acct-2',
account_id: missingAccountId,
name: 'Account 2',
account_sync_source: 'simpleFin',
});
await db.insertPayee({
id: 'transfer-' + acct2Id,
name: '',
transfer_acct: acct2Id,
});
const results = await simpleFinBatchSync([
{ id: 'acct-1', account_id: presentAccountId },
{ id: 'acct-2', account_id: missingAccountId },
]);
// The missing account should get the ACCOUNT_MISSING error from the errors map
const missingResult = results.find(r => r.accountId === 'acct-2');
expect(missingResult).toBeDefined();
expect(missingResult.res.error_code).toBe('ACCOUNT_MISSING');
expect(missingResult.res.error_type).toBe('ACCOUNT_MISSING');
});
});

View File

@@ -18,7 +18,6 @@ import {
import type {
AccountEntity,
BankSyncResponse,
SimpleFinBatchSyncResponse,
TransactionEntity,
} from '../../types/models';
import { aqlQuery } from '../aql';
@@ -226,12 +225,12 @@ async function downloadSimpleFinTransactions(
let retVal = {};
if (batchSync) {
for (const [accountId, data] of Object.entries(
res as SimpleFinBatchSyncResponse,
)) {
const batchErrors = res.errors;
for (const accountId of Object.keys(res)) {
if (accountId === 'errors') continue;
const error = res?.errors?.[accountId]?.[0];
const data = res[accountId];
const error = batchErrors?.[accountId]?.[0];
retVal[accountId] = {
transactions: data?.transactions?.all,
@@ -244,12 +243,31 @@ async function downloadSimpleFinTransactions(
retVal[accountId].error_code = error.error_code;
}
}
// Add entries for accounts that only have errors (no data in the response)
if (batchErrors) {
for (const [accountId, errorList] of Object.entries(batchErrors)) {
if (
!retVal[accountId] &&
Array.isArray(errorList) &&
errorList.length > 0
) {
const error = errorList[0];
retVal[accountId] = {
transactions: [],
accountBalance: [],
startingBalance: 0,
error_type: error.error_type,
error_code: error.error_code,
};
}
}
}
} else {
const singleRes = res as BankSyncResponse;
retVal = {
transactions: singleRes.transactions.all,
accountBalance: singleRes.balances,
startingBalance: singleRes.startingBalance,
transactions: res.transactions.all,
accountBalance: res.balances,
startingBalance: res.startingBalance,
};
}
@@ -1074,11 +1092,34 @@ export async function simpleFinBatchSync(
startDates,
);
if (!res) {
return accounts.map(account => ({
accountId: account.id,
res: {
error_type: 'NO_DATA',
error_code: 'NO_DATA',
},
}));
}
const promises = [];
for (let i = 0; i < accounts.length; i++) {
const account = accounts[i];
const download = res[account.account_id];
if (!download || Object.keys(download).length === 0) {
promises.push(
Promise.resolve({
accountId: account.id,
res: {
error_type: 'ACCOUNT_MISSING',
error_code: 'ACCOUNT_MISSING',
},
}),
);
continue;
}
const acctRow = await db.select('accounts', account.id);
const oldestTransaction = await getAccountOldestTransaction(account.id);
const newAccount = oldestTransaction == null;
@@ -1094,13 +1135,32 @@ export async function simpleFinBatchSync(
continue;
}
if (!download.transactions) {
promises.push(
Promise.resolve({
accountId: account.id,
res: {
error_type: 'ACCOUNT_MISSING',
error_code: 'ACCOUNT_MISSING',
},
}),
);
continue;
}
promises.push(
processBankSyncDownload(download, account.id, acctRow, newAccount).then(
res => ({
processBankSyncDownload(download, account.id, acctRow, newAccount)
.then(res => ({
accountId: account.id,
res,
}),
),
}))
.catch(err => ({
accountId: account.id,
res: {
error_type: err?.category || 'INTERNAL_ERROR',
error_code: err?.code || 'INTERNAL_ERROR',
},
})),
);
}

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [MatissJanis]
---
Handle missing accounts in SimpleFin batch sync with localized error messaging for users.