Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Plaid): ability to reconnect account #10637

Merged
merged 2 commits into from
Feb 20, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion server/graphql/schemaV2.graphql
Original file line number Diff line number Diff line change
@@ -20711,7 +20711,27 @@ type Mutation {
"""
Generate a Plaid Link token
"""
generatePlaidLinkToken: PlaidLinkTokenCreateResponse!
generatePlaidLinkToken(
"""
The account to which the Plaid account should be connected
"""
host: AccountReferenceInput!

"""
Use this parameter to specify the import to update (when using the Plaid update flow)
"""
transactionImport: TransactionsImportReferenceInput

"""
The countries to enable in the accounts selection. Defaults to the host country.
"""
countries: [CountryISO!]

"""
The language to use in the Plaid Link flow. Defaults to "en".
"""
locale: Locale
): PlaidLinkTokenCreateResponse!

"""
Connect a Plaid account
@@ -23180,6 +23200,18 @@ type PlaidLinkTokenCreateResponse {
hostedLinkUrl: String
}

input TransactionsImportReferenceInput {
"""
The id of the row
"""
id: NonEmptyString!
}

"""
The locale in the format of a BCP 47 (RFC 5646) standard string
"""
scalar Locale

type PlaidConnectAccountResponse {
"""
The connected account that was created
36 changes: 36 additions & 0 deletions server/graphql/v2/input/TransactionsImportReferenceInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { GraphQLInputObjectType, GraphQLNonNull } from 'graphql';
import { GraphQLNonEmptyString } from 'graphql-scalars';

import { TransactionsImport } from '../../../models';
import { idDecode } from '../identifiers';

export type GraphQLTransactionsImportReferenceInputFields = {
id: string;
};

export const GraphQLTransactionsImportReferenceInput = new GraphQLInputObjectType({
name: 'TransactionsImportReferenceInput',
fields: () => ({
id: {
type: new GraphQLNonNull(GraphQLNonEmptyString),
description: 'The id of the row',
},
}),
});

export const fetchTransactionsImportWithReference = async (
input: GraphQLTransactionsImportReferenceInputFields,
{ throwIfMissing = false, ...sequelizeOpts } = {},
): Promise<TransactionsImport> => {
let row;
if (input.id) {
const decodedId = idDecode(input.id, 'transactions-import-row');
row = await TransactionsImport.findByPk(decodedId, sequelizeOpts);
}

if (!row && throwIfMissing) {
throw new Error(`TransactionsImport not found`);
}

return row;
};
83 changes: 76 additions & 7 deletions server/graphql/v2/mutation/PlaidMutations.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
import { GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql';
import { pick } from 'lodash';
import { GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql';
import { GraphQLLocale } from 'graphql-scalars';
import { isEmpty, pick } from 'lodash';

import PlatformConstants from '../../../constants/platform';
import { connectPlaidAccount, generatePlaidLinkToken } from '../../../lib/plaid/connect';
import { requestPlaidAccountSync } from '../../../lib/plaid/sync';
import RateLimit from '../../../lib/rate-limit';
import { checkRemoteUserCanUseTransactions } from '../../common/scope-check';
import { Forbidden, RateLimitExceeded } from '../../errors';
import { fetchAccountWithReference, GraphQLAccountReferenceInput } from '../input/AccountReferenceInput';
import { GraphQLCountryISO } from '../enum';
import {
AccountReferenceInput,
fetchAccountWithReference,
GraphQLAccountReferenceInput,
} from '../input/AccountReferenceInput';
import {
fetchConnectedAccountWithReference,
GraphQLConnectedAccountReferenceInput,
} from '../input/ConnectedAccountReferenceInput';
import {
fetchTransactionsImportWithReference,
GraphQLTransactionsImportReferenceInput,
GraphQLTransactionsImportReferenceInputFields,
} from '../input/TransactionsImportReferenceInput';
import { GraphQLConnectedAccount } from '../object/ConnectedAccount';
import { GraphQLTransactionsImport } from '../object/TransactionsImport';

@@ -56,7 +67,25 @@ export const plaidMutations = {
generatePlaidLinkToken: {
type: new GraphQLNonNull(GraphQLPlaidLinkTokenCreateResponse),
description: 'Generate a Plaid Link token',
resolve: async (_, _args, req: Express.Request) => {
args: {
host: {
type: new GraphQLNonNull(GraphQLAccountReferenceInput),
description: 'The account to which the Plaid account should be connected',
},
transactionImport: {
type: GraphQLTransactionsImportReferenceInput,
description: 'Use this parameter to specify the import to update (when using the Plaid update flow)',
},
countries: {
type: new GraphQLList(new GraphQLNonNull(GraphQLCountryISO)),
description: 'The countries to enable in the accounts selection. Defaults to the host country.',
},
locale: {
type: GraphQLLocale,
description: 'The language to use in the Plaid Link flow. Defaults to "en".',
},
},
resolve: async (_, args, req: Express.Request) => {
checkRemoteUserCanUseTransactions(req);

// Check if user is an admin of any third party host or platform
@@ -77,7 +106,37 @@ export const plaidMutations = {
);
}

const tokenData = await generatePlaidLinkToken(req.remoteUser, ['auth', 'transactions'], ['US']);
const host = await fetchAccountWithReference(args.host, { throwIfMissing: true });
if (!req.remoteUser.isAdminOfCollective(host)) {
throw new Forbidden('You do not have permission to connect a Plaid account for this host');
}

const params: Parameters<typeof generatePlaidLinkToken>[1] = {
products: ['auth', 'transactions'],
countries: isEmpty(args.countries) ? [host.countryISO || 'US'] : args.countries,
locale: args.locale || 'en',
};

if (args.transactionImport) {
const transactionImport = await fetchTransactionsImportWithReference(args.transactionImport, {
throwIfMissing: true,
});
if (transactionImport.CollectiveId !== host.id) {
throw new Forbidden('You do not have permission to update this import');
}

const connectedAccount = await transactionImport.getConnectedAccount();
if (!connectedAccount) {
throw new Error('Connected account not found');
} else if (connectedAccount.CollectiveId !== host.id) {
throw new Forbidden('You do not have permission to update the connection for this import');
}

params.accessToken = connectedAccount.token;
}

const tokenData = await generatePlaidLinkToken(req.remoteUser, params);

return {
linkToken: tokenData['link_token'],
expiration: tokenData['expiration'],
@@ -107,7 +166,17 @@ export const plaidMutations = {
description: 'The name of the bank account',
},
},
resolve: async (_, args, req) => {
resolve: async (
_,
args: {
host: AccountReferenceInput;
transactionImport: GraphQLTransactionsImportReferenceInputFields;
publicToken: string;
sourceName?: string;
name?: string;
},
req,
) => {
checkRemoteUserCanUseTransactions(req);
const host = await fetchAccountWithReference(args.host, { throwIfMissing: true });
if (!req.remoteUser.isAdminOfCollective(host)) {
@@ -121,7 +190,7 @@ export const plaidMutations = {
);
}

const accountInfo = pick(args, ['sourceName', 'name']);
const accountInfo: Parameters<typeof connectPlaidAccount>[3] = pick(args, ['sourceName', 'name']);
return connectPlaidAccount(req.remoteUser, host, args.publicToken, accountInfo);
},
},
65 changes: 57 additions & 8 deletions server/lib/plaid/connect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { truncate } from 'lodash';
import { omit, truncate } from 'lodash';
import { CountryCode, ItemPublicTokenExchangeResponse, Products } from 'plaid';

import { Service } from '../../constants/connected-account';
@@ -9,22 +9,62 @@ import { reportErrorToSentry } from '../sentry';
import { getPlaidClient } from './client';
import { getPlaidWebhookUrl } from './webhooks';

// See https://plaid.com/docs/api/link/#link-token-create-request-language
const PlaidSupportedLocales = [
'da',
'nl',
'en',
'et',
'fr',
'de',
'hi',
'it',
'lv',
'lt',
'no',
'pl',
'pt',
'ro',
'es',
'sv',
'vi',
] as const;

const getPlaidLanguage = (locale: string): (typeof PlaidSupportedLocales)[number] => {
if (locale) {
locale = locale.toLowerCase().split('-')[0].trim();
if ((PlaidSupportedLocales as readonly string[]).includes(locale)) {
return locale as (typeof PlaidSupportedLocales)[number];
}
}

return 'en';
};

export const generatePlaidLinkToken = async (
remoteUser: User,
products: readonly (Products | `${Products}`)[],
countryCodes: readonly (CountryCode | `${CountryCode}`)[],
params: {
products: readonly (Products | `${Products}`)[];
countries: readonly (CountryCode | `${CountryCode}`)[];
locale: string;
accessToken?: string;
},
) => {
const linkTokenConfig = {
/* eslint-disable camelcase */
user: { client_user_id: remoteUser.id.toString() },
client_name: PlatformConstants.PlatformName,
language: 'en',
products: products as Products[],
country_codes: countryCodes as CountryCode[],
language: getPlaidLanguage(params.locale),
products: params.products as Products[],
country_codes: params.countries as CountryCode[],
webhook: getPlaidWebhookUrl(),
/* eslint-enable camelcase */
};

if (params.accessToken) {
linkTokenConfig['access_token'] = params.accessToken;
}

try {
const PlaidClient = getPlaidClient();
const tokenResponse = await PlaidClient.linkTokenCreate(linkTokenConfig);
@@ -39,7 +79,7 @@ export const connectPlaidAccount = async (
remoteUser: User,
host: Collective,
publicToken: string,
{ sourceName, name }: { sourceName: string; name: string },
{ sourceName, name }: { sourceName?: string; name?: string },
) => {
// Permissions check
if (!remoteUser.isAdminOfCollective(host)) {
@@ -80,6 +120,7 @@ export const connectPlaidAccount = async (
clientId: exchangeTokenResponse['item_id'],
token: exchangeTokenResponse['access_token'],
CreatedByUserId: remoteUser.id,
data: omit(exchangeTokenResponse, ['item_id', 'access_token']),
},
{ transaction },
);
@@ -97,7 +138,15 @@ export const connectPlaidAccount = async (
);

// Record the transactions import ID in the connected account for audit purposes
await connectedAccount.update({ data: { transactionsImportId: transactionsImport.id } }, { transaction });
await connectedAccount.update(
{
data: {
...connectedAccount.data,
transactionsImportId: transactionsImport.id,
},
},
{ transaction },
);

return { connectedAccount, transactionsImport };
});
18 changes: 14 additions & 4 deletions test/server/graphql/v2/mutation/PlaidMutations.test.ts
Original file line number Diff line number Diff line change
@@ -39,8 +39,8 @@ describe('server/graphql/v2/mutation/PlaidMutations', () => {

describe('generatePlaidLinkToken', () => {
const GENERATE_PLAID_LINK_TOKEN_MUTATION = gql`
mutation GeneratePlaidLinkToken {
generatePlaidLinkToken {
mutation GeneratePlaidLinkToken($host: AccountReferenceInput!) {
generatePlaidLinkToken(host: $host) {
linkToken
expiration
requestId
@@ -50,15 +50,25 @@ describe('server/graphql/v2/mutation/PlaidMutations', () => {

it('must be the member of a 1st party host or platform', async () => {
const remoteUser = await fakeUser();
const result = await graphqlQueryV2(GENERATE_PLAID_LINK_TOKEN_MUTATION, {}, remoteUser);
const host = await fakeActiveHost({ admin: remoteUser });
const result = await graphqlQueryV2(
GENERATE_PLAID_LINK_TOKEN_MUTATION,
{ host: { legacyId: host.id } },
remoteUser,
);
expect(result.errors).to.exist;
expect(result.errors[0].message).to.equal('You do not have permission to connect a Plaid account');
});

it('should generate a Plaid Link token', async () => {
const remoteUser = await fakeUser({ data: { isRoot: true } });
const host = await fakeActiveHost({ admin: remoteUser });
await platform.addUserWithRole(remoteUser, 'ADMIN');
const result = await graphqlQueryV2(GENERATE_PLAID_LINK_TOKEN_MUTATION, {}, remoteUser);
const result = await graphqlQueryV2(
GENERATE_PLAID_LINK_TOKEN_MUTATION,
{ host: { legacyId: host.id } },
remoteUser,
);
result.errors && console.error(result.errors);
expect(result.errors).to.not.exist;
const tokenResponse = result.data.generatePlaidLinkToken;