diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index b78573b3c644e04d956063611544322b693a2f57..ebec57e5e4be0da398a90781dfe9fba5507bc7a6 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -32,6 +32,7 @@ import { PlaidExternalMsg } from './modals/PlaidExternalMsg'; import { ReportBudgetSummary } from './modals/ReportBudgetSummary'; import { RolloverBudgetSummary } from './modals/RolloverBudgetSummary'; import { SelectLinkedAccounts } from './modals/SelectLinkedAccounts'; +import { SimpleFinInitialise } from './modals/SimpleFinInitialise'; import { SingleInput } from './modals/SingleInput'; import { SwitchBudgetType } from './modals/SwitchBudgetType'; import { DiscoverSchedules } from './schedules/DiscoverSchedules'; @@ -80,6 +81,7 @@ export function Modals() { <CreateAccount modalProps={modalProps} syncServerStatus={syncServerStatus} + upgradingAccountId={options?.upgradingAccountId} /> ); @@ -109,6 +111,7 @@ export function Modals() { requisitionId={options.requisitionId} localAccounts={accounts.filter(acct => acct.closed === 0)} actions={actions} + syncSource={options.syncSource} /> ); @@ -196,6 +199,14 @@ export function Modals() { /> ); + case 'simplefin-init': + return ( + <SimpleFinInitialise + modalProps={modalProps} + onSuccess={options.onSuccess} + /> + ); + case 'gocardless-external-msg': return ( <GoCardlessExternalMsg diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx index e61c46c68ad08befe6a900f0e2219e07662f5aa7..37653a025b81d6de294311e56c2be47945431422 100644 --- a/packages/desktop-client/src/components/accounts/Account.jsx +++ b/packages/desktop-client/src/components/accounts/Account.jsx @@ -26,7 +26,6 @@ import { } from 'loot-core/src/shared/transactions'; import { applyChanges, groupById } from 'loot-core/src/shared/util'; -import { authorizeBank } from '../../gocardless'; import { useCategories } from '../../hooks/useCategories'; import { SelectedProviderWithItems } from '../../hooks/useSelected'; import { styles, theme } from '../../style'; @@ -589,7 +588,9 @@ class AccountInternal extends PureComponent { switch (item) { case 'link': - authorizeBank(this.props.pushModal, { upgradingAccountId: accountId }); + this.props.pushModal('add-account', { + upgradingAccountId: accountId, + }); break; case 'unlink': this.props.unlinkAccount(accountId); diff --git a/packages/desktop-client/src/components/modals/CreateAccount.tsx b/packages/desktop-client/src/components/modals/CreateAccount.tsx index fab820fb99a9226e0612e1d25ba58db7e9aca109..c577ab7f8d220c0d968857f2df070df1306a4aba 100644 --- a/packages/desktop-client/src/components/modals/CreateAccount.tsx +++ b/packages/desktop-client/src/components/modals/CreateAccount.tsx @@ -1,9 +1,13 @@ // @ts-strict-ignore import React, { useEffect, useState } from 'react'; +import { send } from 'loot-core/src/platform/client/fetch'; + import { authorizeBank } from '../../gocardless'; import { useActions } from '../../hooks/useActions'; +import { useFeatureFlag } from '../../hooks/useFeatureFlag'; import { useGoCardlessStatus } from '../../hooks/useGoCardlessStatus'; +import { useSimpleFinStatus } from '../../hooks/useSimpleFinStatus'; import { type SyncServerStatus } from '../../hooks/useSyncServerStatus'; import { theme } from '../../style'; import { type CommonModalProps } from '../../types/modals'; @@ -17,23 +21,75 @@ import { View } from '../common/View'; type CreateAccountProps = { modalProps: CommonModalProps; syncServerStatus: SyncServerStatus; + upgradingAccountId?: string; }; export function CreateAccount({ modalProps, syncServerStatus, + upgradingAccountId, }: CreateAccountProps) { const actions = useActions(); const [isGoCardlessSetupComplete, setIsGoCardlessSetupComplete] = useState(null); + const [isSimpleFinSetupComplete, setIsSimpleFinSetupComplete] = + useState(null); - const onConnect = () => { + const onConnectGoCardless = () => { if (!isGoCardlessSetupComplete) { onGoCardlessInit(); return; } - authorizeBank(actions.pushModal); + if (upgradingAccountId == null) { + authorizeBank(actions.pushModal); + } else { + authorizeBank(actions.pushModal, { + upgradingAccountId, + }); + } + }; + + const onConnectSimpleFin = async () => { + if (!isSimpleFinSetupComplete) { + onSimpleFinInit(); + return; + } + + if (loadingSimpleFinAccounts) { + return; + } + + setLoadingSimpleFinAccounts(true); + + const results = await send('simplefin-accounts'); + + const newAccounts = []; + + type NormalizedAccount = { + account_id: string; + name: string; + institution: string; + orgDomain: string; + }; + + for (const oldAccount of results.accounts) { + const newAccount: NormalizedAccount = { + account_id: oldAccount.id, + name: oldAccount.name, + institution: oldAccount.org.name, + orgDomain: oldAccount.org.domain, + }; + + newAccounts.push(newAccount); + } + + actions.pushModal('select-linked-accounts', { + accounts: newAccounts, + syncSource: 'simpleFin', + }); + + setLoadingSimpleFinAccounts(false); }; const onGoCardlessInit = () => { @@ -42,45 +98,68 @@ export function CreateAccount({ }); }; + const onSimpleFinInit = () => { + actions.pushModal('simplefin-init', { + onSuccess: () => setIsSimpleFinSetupComplete(true), + }); + }; + const onCreateLocalAccount = () => { actions.pushModal('add-local-account'); }; - const { configured } = useGoCardlessStatus(); + const { configuredGoCardless } = useGoCardlessStatus(); useEffect(() => { - setIsGoCardlessSetupComplete(configured); - }, [configured]); + setIsGoCardlessSetupComplete(configuredGoCardless); + }, [configuredGoCardless]); + + const { configuredSimpleFin } = useSimpleFinStatus(); + useEffect(() => { + setIsSimpleFinSetupComplete(configuredSimpleFin); + }, [configuredSimpleFin]); + + let title = 'Add Account'; + const [loadingSimpleFinAccounts, setLoadingSimpleFinAccounts] = + useState(false); + + if (upgradingAccountId != null) { + title = 'Link Account'; + } + + const simpleFinSyncFeatureFlag = useFeatureFlag('simpleFinSync'); return ( - <Modal title="Add Account" {...modalProps}> + <Modal title={title} {...modalProps}> {() => ( <View style={{ maxWidth: 500, gap: 30, color: theme.pageText }}> - <View style={{ gap: 10 }}> - <Button - type="primary" - style={{ - padding: '10px 0', - fontSize: 15, - fontWeight: 600, - }} - onClick={onCreateLocalAccount} - > - Create local account - </Button> - <View style={{ lineHeight: '1.4em', fontSize: 15 }}> - <Text> - <strong>Create a local account</strong> if you want to add - transactions manually. You can also{' '} - <ExternalLink - to="https://actualbudget.org/docs/transactions/importing" - linkColor="muted" - > - import QIF/OFX/QFX files into a local account - </ExternalLink> - . - </Text> + {upgradingAccountId == null && ( + <View style={{ gap: 10 }}> + <Button + type="primary" + style={{ + padding: '10px 0', + fontSize: 15, + fontWeight: 600, + }} + onClick={onCreateLocalAccount} + > + Create local account + </Button> + <View style={{ lineHeight: '1.4em', fontSize: 15 }}> + <Text> + <strong>Create a local account</strong> if you want to add + transactions manually. You can also{' '} + <ExternalLink + to="https://actualbudget.org/docs/transactions/importing" + linkColor="muted" + > + import QIF/OFX/QFX files into a local account + </ExternalLink> + . + </Text> + </View> </View> - </View> + )} <View style={{ gap: 10 }}> {syncServerStatus === 'online' ? ( <> @@ -92,17 +171,46 @@ export function CreateAccount({ fontWeight: 600, flex: 1, }} - onClick={onConnect} + onClick={onConnectGoCardless} > {isGoCardlessSetupComplete ? 'Link bank account with GoCardless' : 'Set up GoCardless for bank sync'} </ButtonWithLoading> <Text style={{ lineHeight: '1.4em', fontSize: 15 }}> - <strong>Link a bank account</strong> to automatically download - transactions. GoCardless provides reliable, up-to-date - information from hundreds of banks. + <strong> + Link a <u>European</u> bank account + </strong>{' '} + to automatically download transactions. GoCardless provides + reliable, up-to-date information from hundreds of banks. </Text> + {simpleFinSyncFeatureFlag === true && ( + <> + <ButtonWithLoading + disabled={syncServerStatus !== 'online'} + loading={loadingSimpleFinAccounts} + style={{ + marginTop: '18px', + padding: '10px 0', + fontSize: 15, + fontWeight: 600, + flex: 1, + }} + onClick={onConnectSimpleFin} + > + {isSimpleFinSetupComplete + ? 'Link bank account with SimpleFIN' + : 'Set up SimpleFIN for bank sync'} + </ButtonWithLoading> + <Text style={{ lineHeight: '1.4em', fontSize: 15 }}> + <strong> + Link a <u>North American</u> bank account + </strong>{' '} + to automatically download transactions. SimpleFIN provides + reliable, up-to-date information from hundreds of banks. + </Text> + </> + )} </> ) : ( <> @@ -114,7 +222,7 @@ export function CreateAccount({ fontWeight: 600, }} > - Set up GoCardless for bank sync + Set up bank sync </Button> <Paragraph style={{ fontSize: 15 }}> Connect to an Actual server to set up{' '} @@ -122,7 +230,7 @@ export function CreateAccount({ to="https://actualbudget.org/docs/advanced/bank-sync" linkColor="muted" > - automatic syncing with GoCardless + automatic syncing. </ExternalLink> . </Paragraph> diff --git a/packages/desktop-client/src/components/modals/GoCardlessExternalMsg.tsx b/packages/desktop-client/src/components/modals/GoCardlessExternalMsg.tsx index dbb74a9562bd0fdff7fe03b69067df3706b7db51..9e3b4cfc9b11098c22f68d3d92d18e9d07fae7f9 100644 --- a/packages/desktop-client/src/components/modals/GoCardlessExternalMsg.tsx +++ b/packages/desktop-client/src/components/modals/GoCardlessExternalMsg.tsx @@ -110,8 +110,10 @@ export function GoCardlessExternalMsg({ isLoading: isBankOptionsLoading, isError: isBankOptionError, } = useAvailableBanks(country); - const { configured: isConfigured, isLoading: isConfigurationLoading } = - useGoCardlessStatus(); + const { + configuredGoCardless: isConfigured, + isLoading: isConfigurationLoading, + } = useGoCardlessStatus(); async function onJump() { setError(null); diff --git a/packages/desktop-client/src/components/modals/SelectLinkedAccounts.jsx b/packages/desktop-client/src/components/modals/SelectLinkedAccounts.jsx index a5a2aadcd1edb38ec411c22eb18db203edbfcc76..8d8d82475bc1e5f7b4b448eb0a3b1a3e3350647c 100644 --- a/packages/desktop-client/src/components/modals/SelectLinkedAccounts.jsx +++ b/packages/desktop-client/src/components/modals/SelectLinkedAccounts.jsx @@ -16,6 +16,7 @@ export function SelectLinkedAccounts({ externalAccounts, localAccounts, actions, + syncSource, }) { const [chosenAccounts, setChosenAccounts] = useState(() => { return Object.fromEntries( @@ -49,13 +50,22 @@ export function SelectLinkedAccounts({ } // Finally link the matched account - actions.linkAccount( - requisitionId, - externalAccount, - chosenLocalAccountId !== addAccountOption.id - ? chosenLocalAccountId - : undefined, - ); + if (syncSource === 'simpleFin') { + actions.linkAccountSimpleFin( + externalAccount, + chosenLocalAccountId !== addAccountOption.id + ? chosenLocalAccountId + : undefined, + ); + } else { + actions.linkAccount( + requisitionId, + externalAccount, + chosenLocalAccountId !== addAccountOption.id + ? chosenLocalAccountId + : undefined, + ); + } }, ); diff --git a/packages/desktop-client/src/components/modals/SimpleFinInitialise.tsx b/packages/desktop-client/src/components/modals/SimpleFinInitialise.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4db5bff8822bc9291ad3989384d479ef0d5c5eca --- /dev/null +++ b/packages/desktop-client/src/components/modals/SimpleFinInitialise.tsx @@ -0,0 +1,88 @@ +// @ts-strict-ignore +import React, { useState } from 'react'; + +import { send } from 'loot-core/src/platform/client/fetch'; + +import { Error } from '../alerts'; +import { ButtonWithLoading } from '../common/Button'; +import { ExternalLink } from '../common/ExternalLink'; +import { Input } from '../common/Input'; +import { Modal, ModalButtons } from '../common/Modal'; +import type { ModalProps } from '../common/Modal'; +import { Text } from '../common/Text'; +import { View } from '../common/View'; +import { FormField, FormLabel } from '../forms'; + +type SimpleFinInitialiseProps = { + modalProps?: Partial<ModalProps>; + onSuccess: () => void; +}; + +export const SimpleFinInitialise = ({ + modalProps, + onSuccess, +}: SimpleFinInitialiseProps) => { + const [token, setToken] = useState(''); + const [isValid, setIsValid] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const onSubmit = async () => { + if (!token) { + setIsValid(false); + return; + } + + setIsLoading(true); + + await send('secret-set', { + name: 'simplefin_token', + value: token, + }); + + onSuccess(); + modalProps.onClose(); + setIsLoading(false); + }; + + return ( + <Modal title="Set-up SimpleFIN" size={{ width: 300 }} {...modalProps}> + <View style={{ display: 'flex', gap: 10 }}> + <Text> + In order to enable bank-sync via SimpleFIN (only for North American + banks) you will need to create a token. This can be done by creating + an account with{' '} + <ExternalLink + to="https://beta-bridge.simplefin.org/" + linkColor="purple" + > + SimpleFIN + </ExternalLink> + . + </Text> + + <FormField> + <FormLabel title="Token:" htmlFor="token-field" /> + <Input + id="token-field" + type="password" + value={token} + onUpdate={setToken} + onChange={() => setIsValid(true)} + /> + </FormField> + + {!isValid && <Error>It is required to provide a token.</Error>} + </View> + + <ModalButtons> + <ButtonWithLoading + type="primary" + loading={isLoading} + onClick={onSubmit} + > + Save and continue + </ButtonWithLoading> + </ModalButtons> + </Modal> + ); +}; diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index 28ed21722375b7ab5bb64cec703ad2fa17ca40df..6a67a406e1cd1abcc006b7f8c751c65c76cae478 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -94,6 +94,7 @@ export function ExperimentalFeatures() { <FeatureToggle flag="goalTemplatesEnabled"> Goal templates </FeatureToggle> + <FeatureToggle flag="simpleFinSync">SimpleFIN sync</FeatureToggle> </View> ) : ( <LinkButton diff --git a/packages/desktop-client/src/gocardless.ts b/packages/desktop-client/src/gocardless.ts index 6f5e27794ed55c58787a8dba25e0b1cc5c4daf65..809c9f86c6edbc0b386ef6304a23b5834137a52f 100644 --- a/packages/desktop-client/src/gocardless.ts +++ b/packages/desktop-client/src/gocardless.ts @@ -47,6 +47,7 @@ export async function authorizeBank( accounts: data.accounts, requisitionId: data.id, upgradingAccountId, + syncSource: 'goCardless', }); }, }); diff --git a/packages/desktop-client/src/hooks/useFeatureFlag.ts b/packages/desktop-client/src/hooks/useFeatureFlag.ts index 5b506dc4d58b1cdea66323748a4df93286f1e2e8..08338bb3c0a7fb82b6ba715dcefc1f189fa1e4f8 100644 --- a/packages/desktop-client/src/hooks/useFeatureFlag.ts +++ b/packages/desktop-client/src/hooks/useFeatureFlag.ts @@ -9,6 +9,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = { reportBudget: false, goalTemplatesEnabled: false, customReports: false, + simpleFinSync: false, }; export function useFeatureFlag(name: FeatureFlag): boolean { diff --git a/packages/desktop-client/src/hooks/useGoCardlessStatus.ts b/packages/desktop-client/src/hooks/useGoCardlessStatus.ts index 22927722e2a9eff5ff0d85a15890f25e933cc006..9b65cd43494b7191ef9e0e7317c78fa4ab3f7bd5 100644 --- a/packages/desktop-client/src/hooks/useGoCardlessStatus.ts +++ b/packages/desktop-client/src/hooks/useGoCardlessStatus.ts @@ -5,7 +5,9 @@ import { send } from 'loot-core/src/platform/client/fetch'; import { useSyncServerStatus } from './useSyncServerStatus'; export function useGoCardlessStatus() { - const [configured, setConfigured] = useState<boolean | null>(null); + const [configuredGoCardless, setConfiguredGoCardless] = useState< + boolean | null + >(null); const [isLoading, setIsLoading] = useState(false); const status = useSyncServerStatus(); @@ -15,7 +17,7 @@ export function useGoCardlessStatus() { const results = await send('gocardless-status'); - setConfigured(results.configured || false); + setConfiguredGoCardless(results.configured || false); setIsLoading(false); } @@ -25,7 +27,7 @@ export function useGoCardlessStatus() { }, [status]); return { - configured, + configuredGoCardless, isLoading, }; } diff --git a/packages/desktop-client/src/hooks/useSimpleFinStatus.ts b/packages/desktop-client/src/hooks/useSimpleFinStatus.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3f4a06fae185dc052464e7bb9d4e6178df37790 --- /dev/null +++ b/packages/desktop-client/src/hooks/useSimpleFinStatus.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; + +import { send } from 'loot-core/src/platform/client/fetch'; + +import { useSyncServerStatus } from './useSyncServerStatus'; + +export function useSimpleFinStatus() { + const [configuredSimpleFin, setConfiguredSimpleFin] = useState< + boolean | null + >(null); + const [isLoading, setIsLoading] = useState(false); + const status = useSyncServerStatus(); + + useEffect(() => { + async function fetch() { + setIsLoading(true); + + const results = await send('simplefin-status'); + + setConfiguredSimpleFin(results.configured || false); + setIsLoading(false); + } + + if (status === 'online') { + fetch(); + } + }, [status]); + + return { + configuredSimpleFin, + isLoading, + }; +} diff --git a/packages/loot-core/migrations/1704572023730_add_account_sync_source.sql b/packages/loot-core/migrations/1704572023730_add_account_sync_source.sql new file mode 100644 index 0000000000000000000000000000000000000000..3aae8da909ef0f62ce3f63cdd5a4f1487001b8b4 --- /dev/null +++ b/packages/loot-core/migrations/1704572023730_add_account_sync_source.sql @@ -0,0 +1,11 @@ +BEGIN TRANSACTION; + +ALTER TABLE accounts ADD COLUMN account_sync_source TEXT; + +UPDATE accounts SET + account_sync_source = CASE + WHEN account_id THEN 'goCardless' + ELSE NULL + END; + +COMMIT; diff --git a/packages/loot-core/src/client/actions/account.ts b/packages/loot-core/src/client/actions/account.ts index 3591f96a8661b25fe9af1f809c817472bb582d31..c8349d84c578607b951c5dedb941ca14274a4bf7 100644 --- a/packages/loot-core/src/client/actions/account.ts +++ b/packages/loot-core/src/client/actions/account.ts @@ -76,6 +76,17 @@ export function linkAccount(requisitionId, account, upgradingId) { }; } +export function linkAccountSimpleFin(externalAccount, upgradingId) { + return async (dispatch: Dispatch) => { + await send('simplefin-accounts-link', { + externalAccount, + upgradingId, + }); + await dispatch(getPayees()); + await dispatch(getAccounts()); + }; +} + // TODO: type correctly or remove (unused) export function connectAccounts( institution, diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts index c1d3a9ee339a71fc268183e421804d6a88baf57f..e25e38feb327a3ee438d2714547abcecc13d6115 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -39,6 +39,7 @@ type FinanceModals = { accounts: unknown[]; requisitionId: string; upgradingAccountId?: string; + syncSource?: AccountSyncSource; }; 'confirm-category-delete': { onDelete: () => void } & ( @@ -67,6 +68,10 @@ type FinanceModals = { 'gocardless-init': { onSuccess: () => void; }; + 'simplefin-init': { + onSuccess: () => void; + }; + 'gocardless-external-msg': { onMoveExternal: (arg: { institutionId: string; diff --git a/packages/loot-core/src/server/accounts/link.ts b/packages/loot-core/src/server/accounts/link.ts index 52e8d071b9084b098637ef8a26e268743b5a3737..ff2618af87d94171bc403a29d805f745bd9a985b 100644 --- a/packages/loot-core/src/server/accounts/link.ts +++ b/packages/loot-core/src/server/accounts/link.ts @@ -144,7 +144,7 @@ export async function addGoCardlessAccounts( }); // Do an initial sync - await bankSync.syncGoCardlessAccount( + await bankSync.syncExternalAccount( userId, userKey, id, diff --git a/packages/loot-core/src/server/accounts/sync.ts b/packages/loot-core/src/server/accounts/sync.ts index fa6d3134057bb8516ec30fce3ddc3dba1bb17dd3..efd4b9cf391dd78ddd85fed1cb3484560a930143 100644 --- a/packages/loot-core/src/server/accounts/sync.ts +++ b/packages/loot-core/src/server/accounts/sync.ts @@ -216,6 +216,38 @@ async function downloadGoCardlessTransactions( }; } +async function downloadSimpleFinTransactions(acctId, since) { + const userToken = await asyncStorage.getItem('user-token'); + if (!userToken) return; + + const res = await post( + getServer().SIMPLEFIN_SERVER + '/transactions', + { + accountId: acctId, + startDate: since, + }, + { + 'X-ACTUAL-TOKEN': userToken, + }, + ); + + if (res.error_code) { + throw BankSyncError(res.error_type, res.error_code); + } + + const { + transactions: { all }, + balances, + startingBalance, + } = res; + + return { + transactions: all, + accountBalance: balances, + startingBalance, + }; +} + async function resolvePayee(trans, payeeName, payeesToCreate) { if (trans.payee == null && payeeName) { // First check our registry of new payees (to avoid a db access) @@ -778,13 +810,7 @@ export async function addTransactions( return newTransactions; } -export async function syncGoCardlessAccount( - userId, - userKey, - id, - acctId, - bankId, -) { +export async function syncExternalAccount(userId, userKey, id, acctId, bankId) { // TODO: Handle the case where transactions exist in the future // (that will make start date after end date) const latestTransaction = await db.first( @@ -814,14 +840,21 @@ export async function syncGoCardlessAccount( ]), ); - const { transactions: originalTransactions, accountBalance } = - await downloadGoCardlessTransactions( + let download; + + if (acctRow.account_sync_source === 'simpleFin') { + download = await downloadSimpleFinTransactions(acctId, startDate); + } else if (acctRow.account_sync_source === 'goCardless') { + download = await downloadGoCardlessTransactions( userId, userKey, acctId, bankId, startDate, ); + } + + const { transactions: originalTransactions, accountBalance } = download; if (originalTransactions.length === 0) { return { added: [], updated: [] }; @@ -841,20 +874,33 @@ export async function syncGoCardlessAccount( // Otherwise, download transaction for the past 90 days const startingDay = monthUtils.subDays(monthUtils.currentDay(), 90); - const { transactions, startingBalance } = - await downloadGoCardlessTransactions( + let download; + + if (acctRow.account_sync_source === 'simpleFin') { + download = await downloadSimpleFinTransactions(acctId, startingDay); + } else if (acctRow.account_sync_source === 'goCardless') { + download = await downloadGoCardlessTransactions( userId, userKey, acctId, bankId, startingDay, ); + } - // We need to add a transaction that represents the starting - // balance for everything to balance out. In order to get balance - // before the first imported transaction, we need to get the - // current balance from the accounts table and subtract all the - // imported transactions. + const { transactions, startingBalance } = download; + + let balanceToUse = startingBalance; + + if (acctRow.account_sync_source === 'simpleFin') { + const currentBalance = startingBalance; + const previousBalance = transactions.reduce((total, trans) => { + return ( + total - parseInt(trans.transactionAmount.amount.replace('.', '')) + ); + }, currentBalance); + balanceToUse = previousBalance; + } const oldestTransaction = transactions[transactions.length - 1]; @@ -868,7 +914,7 @@ export async function syncGoCardlessAccount( return runMutator(async () => { const initialId = await db.insertTransaction({ account: id, - amount: startingBalance, + amount: balanceToUse, category: acctRow.offbudget === 0 ? payee.category : null, payee: payee.id, date: oldestDate, diff --git a/packages/loot-core/src/server/aql/schema/index.ts b/packages/loot-core/src/server/aql/schema/index.ts index 9d893e6b3dac122576a60c0742df72eaad455974..c129710188dc4f22ff9f5aa7b2c8b959cc01bc78 100644 --- a/packages/loot-core/src/server/aql/schema/index.ts +++ b/packages/loot-core/src/server/aql/schema/index.ts @@ -69,6 +69,7 @@ export const schema = { closed: f('boolean'), sort_order: f('float'), tombstone: f('boolean'), + account_sync_source: f('string'), }, categories: { id: f('id'), diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 0354083439514d5e405c33c4f3f1617983b26d0e..d0ecf74a040bb1ae331958b7b61588cd77ce82dc 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -671,6 +671,7 @@ handlers['gocardless-accounts-link'] = async function ({ id, account_id: account.account_id, bank: bank.id, + account_sync_source: 'goCardless', }); } else { id = uuidv4(); @@ -681,6 +682,7 @@ handlers['gocardless-accounts-link'] = async function ({ name: account.name, official_name: account.official_name, bank: bank.id, + account_sync_source: 'goCardless', }); await db.insertPayee({ name: '', @@ -688,7 +690,7 @@ handlers['gocardless-accounts-link'] = async function ({ }); } - await bankSync.syncGoCardlessAccount( + await bankSync.syncExternalAccount( undefined, undefined, id, @@ -704,6 +706,64 @@ handlers['gocardless-accounts-link'] = async function ({ return 'ok'; }; +handlers['simplefin-accounts-link'] = async function ({ + externalAccount, + upgradingId, +}) { + let id; + + const institution = { + name: externalAccount.institution ?? 'Unknown', + }; + + const bank = await link.findOrCreateBank( + institution, + externalAccount.orgDomain, + ); + + if (upgradingId) { + const accRow = await db.first('SELECT * FROM accounts WHERE id = ?', [ + upgradingId, + ]); + id = accRow.id; + await db.update('accounts', { + id, + account_id: externalAccount.account_id, + bank: bank.id, + account_sync_source: 'simpleFin', + }); + } else { + id = uuidv4(); + await db.insertWithUUID('accounts', { + id, + account_id: externalAccount.account_id, + name: externalAccount.name, + official_name: externalAccount.name, + bank: bank.id, + account_sync_source: 'simpleFin', + }); + await db.insertPayee({ + name: '', + transfer_acct: id, + }); + } + + await bankSync.syncExternalAccount( + undefined, + undefined, + id, + externalAccount.account_id, + bank.bank_id, + ); + + await connection.send('sync-event', { + type: 'success', + tables: ['transactions'], + }); + + return 'ok'; +}; + handlers['accounts-connect'] = async function ({ institution, publicToken, @@ -1119,6 +1179,38 @@ handlers['gocardless-status'] = async function () { ); }; +handlers['simplefin-status'] = async function () { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + return post( + getServer().SIMPLEFIN_SERVER + '/status', + {}, + { + 'X-ACTUAL-TOKEN': userToken, + }, + ); +}; + +handlers['simplefin-accounts'] = async function () { + const userToken = await asyncStorage.getItem('user-token'); + + if (!userToken) { + return { error: 'unauthorized' }; + } + + return post( + getServer().SIMPLEFIN_SERVER + '/accounts', + {}, + { + 'X-ACTUAL-TOKEN': userToken, + }, + ); +}; + handlers['gocardless-get-banks'] = async function (country) { const userToken = await asyncStorage.getItem('user-token'); @@ -1195,7 +1287,7 @@ handlers['gocardless-accounts-sync'] = async function ({ id }) { const acct = accounts[i]; if (acct.bankId) { try { - const res = await bankSync.syncGoCardlessAccount( + const res = await bankSync.syncExternalAccount( userId, userKey, acct.id, @@ -1281,6 +1373,10 @@ handlers['account-unlink'] = mutator(async function ({ id }) { return 'ok'; } + const accRow = await db.first('SELECT * FROM accounts WHERE id = ?', [id]); + + const isGoCardless = accRow.account_sync_source === 'goCardless'; + await db.updateAccount({ id, account_id: null, @@ -1288,8 +1384,13 @@ handlers['account-unlink'] = mutator(async function ({ id }) { balance_current: null, balance_available: null, balance_limit: null, + account_sync_source: null, }); + if (isGoCardless === false) { + return; + } + const { count } = await db.first( 'SELECT COUNT(*) as count FROM accounts WHERE bank = ?', [bankId], diff --git a/packages/loot-core/src/server/server-config.ts b/packages/loot-core/src/server/server-config.ts index cde33082465ab7d16b27bcc8cc29c06d0c1f72c6..446d88eb03555e7f0eff5c7cd059e520cc8da556 100644 --- a/packages/loot-core/src/server/server-config.ts +++ b/packages/loot-core/src/server/server-config.ts @@ -6,6 +6,7 @@ type ServerConfig = { SIGNUP_SERVER: string; PLAID_SERVER: string; GOCARDLESS_SERVER: string; + SIMPLEFIN_SERVER: string; }; let config: ServerConfig | null = null; @@ -33,6 +34,7 @@ export function getServer(url?: string): ServerConfig | null { SIGNUP_SERVER: joinURL(url, '/account'), PLAID_SERVER: joinURL(url, '/plaid'), GOCARDLESS_SERVER: joinURL(url, '/gocardless'), + SIMPLEFIN_SERVER: joinURL(url, '/simplefin'), }; } return config; diff --git a/packages/loot-core/src/shared/transactions.test.ts b/packages/loot-core/src/shared/transactions.test.ts index 6d701ca0e2f822b8ddb62f87f2a4cf7460cedcb4..1f81ecadd4711c8fa7935691b1cf4d3f312e61bf 100644 --- a/packages/loot-core/src/shared/transactions.test.ts +++ b/packages/loot-core/src/shared/transactions.test.ts @@ -30,6 +30,7 @@ function makeTransaction(data: Partial<TransactionEntity>): TransactionEntity { balance_current: null, balance_available: null, balance_limit: null, + account_sync_source: null, }, ...data, }; diff --git a/packages/loot-core/src/types/models/account.d.ts b/packages/loot-core/src/types/models/account.d.ts index 6bc9ce4d460cdedea92d74c29af30b48ff7ed1bc..cb668f6622f9bd7f486997b4002c705fecb34cfb 100644 --- a/packages/loot-core/src/types/models/account.d.ts +++ b/packages/loot-core/src/types/models/account.d.ts @@ -15,4 +15,7 @@ type _SyncFields<T> = { balance_current: T extends true ? number : null; balance_available: T extends true ? number : null; balance_limit: T extends true ? number : null; + account_sync_source: T extends true ? AccountSyncSource : null; }; + +export type AccountSyncSource = 'simpleFin' | 'goCardless'; diff --git a/packages/loot-core/src/types/models/simplefin.d.ts b/packages/loot-core/src/types/models/simplefin.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..5eddc6680ab7ab8963099761e0f0c975de32acd8 --- /dev/null +++ b/packages/loot-core/src/types/models/simplefin.d.ts @@ -0,0 +1,10 @@ +export type SimpleFinOrganization = { + name: string; + domain: string; +}; + +export type SimpleFinAccount = { + id: string; + name: string; + org: SimpleFinOrganization; +}; diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index 31a25962c49751ad636398a630af7f761a50930e..f483d672ef906475a96f17d7b221bb443154c281 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -5,7 +5,8 @@ export type FeatureFlag = | 'sankeyReport' | 'reportBudget' | 'goalTemplatesEnabled' - | 'customReports'; + | 'customReports' + | 'simpleFinSync'; export type LocalPrefs = Partial< { diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts index f5022b44440a5811600fc314ece078733a0fa649..1883a36ccac11d502f297d4a129cc3c2d6afda57 100644 --- a/packages/loot-core/src/types/server-handlers.d.ts +++ b/packages/loot-core/src/types/server-handlers.d.ts @@ -14,6 +14,7 @@ import { CategoryGroupEntity, GoCardlessToken, GoCardlessInstitution, + SimpleFinAccount, PayeeEntity, } from './models'; import { EmptyObject } from './util'; @@ -161,6 +162,11 @@ export interface ServerHandlers { upgradingId; }) => Promise<'ok'>; + 'simplefin-accounts-link': (arg: { + externalAccount; + upgradingId; + }) => Promise<'ok'>; + 'accounts-connect': (arg: { institution; publicToken; @@ -216,6 +222,10 @@ export interface ServerHandlers { 'gocardless-status': () => Promise<{ configured: boolean }>; + 'simplefin-status': () => Promise<{ configured: boolean }>; + + 'simplefin-accounts': () => Promise<{ accounts: SimpleFinAccount[] }>; + 'gocardless-get-banks': (country: string) => Promise<{ data: GoCardlessInstitution[]; error?: { reason: string }; diff --git a/upcoming-release-notes/2188.md b/upcoming-release-notes/2188.md new file mode 100644 index 0000000000000000000000000000000000000000..8029cdb4e41c464744f6bc813166730bbd67dbf7 --- /dev/null +++ b/upcoming-release-notes/2188.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [zachwhelchel,duplaja,lancepick,latetedemelon] +--- + +Add option to link an account with SimpleFIN for syncing transactions.