feat(web): handle adding accounts to institution

This commit is contained in:
Stevan Freeborn
2026-02-24 06:55:37 -06:00
parent 81c45350e8
commit d0b0097032
2 changed files with 215 additions and 123 deletions
@@ -21,12 +21,16 @@ export interface IInstitutionService {
createLinkToken: () => Promise<Result<LinkTokenResponse, Error[]>>; createLinkToken: () => Promise<Result<LinkTokenResponse, Error[]>>;
connect: (publicToken: string, plaidInstitutionId: string) => Promise<Result<boolean, Error[]>>; connect: (publicToken: string, plaidInstitutionId: string) => Promise<Result<boolean, Error[]>>;
getInstitutions: () => Promise<Result<Institution[], Error[]>>; getInstitutions: () => Promise<Result<Institution[], Error[]>>;
getAvailableAccounts: (institutionId: string) => Promise<Result<AvailableAccount[], Error[]>>;
} }
export class InstitutionService implements IInstitutionService { export class InstitutionService implements IInstitutionService {
private readonly client: IClient; private readonly client: IClient;
private readonly endpoints = { private readonly endpoints = {
link: '/api/institutions/link', link: '/api/institutions/link',
available(id: string) {
return `/api/institutions/${id}/available`;
},
connect: '/api/institutions/connect', connect: '/api/institutions/connect',
institutions: '/api/institutions', institutions: '/api/institutions',
}; };
@@ -105,13 +109,47 @@ export class InstitutionService implements IInstitutionService {
return Err([new Error('Failed to get institutions')]); return Err([new Error('Failed to get institutions')]);
} }
} }
async getAvailableAccounts(institutionId: string) {
const request = new ClientRequest(this.endpoints.available(institutionId));
try {
const res = await this.client.get(request);
if (res.ok === false) {
return Err([new Error('Failed to get available accounts')]);
}
const data = await res.json();
return Ok(data.accounts as AvailableAccount[]);
} catch (e) {
console.error(e);
return Err([new Error('Failed to get available accounts')]);
}
}
} }
type LinkTokenResponse = { type LinkTokenResponse = {
linkToken: string; linkToken: string;
}; };
type Account = {
id: string;
name: string;
}
export type Institution = { export type Institution = {
id: string; id: string;
name: string; name: string;
accounts: Account[];
};
export type AvailableAccount = {
providerInstitutionId: string;
providerId: string;
providerName: string;
currentBalance: number;
availableBalance: number;
currencyCode: string;
}; };
+177 -123
View File
@@ -1,156 +1,210 @@
<script setup lang="ts"> <script setup lang="ts">
import { useInstitutionService } from '@/composables/useInstitutionService'; import { useInstitutionService } from '@/composables/useInstitutionService';
import type { Institution } from '@/services/institutionService'; import type { AvailableAccount, Institution } from '@/services/institutionService';
import { useUserStore } from '@/stores/userStore'; import { useUserStore } from '@/stores/userStore';
import { usePlaidLink, type PlaidLinkOptions } from '@jcss/vue-plaid-link'; import { usePlaidLink, type PlaidLinkOptions } from '@jcss/vue-plaid-link';
import { nextTick, ref, watch } from 'vue'; import { nextTick, ref, watch } from 'vue';
const userStore = useUserStore(); const userStore = useUserStore();
const institutionService = useInstitutionService(userStore); const institutionService = useInstitutionService(userStore);
type InstitutionData = type InstitutionData =
| { | {
status: 'loading'; status: 'loading';
} }
| { status: 'loaded'; data: Institution[] } | { status: 'loaded'; data: Institution[] }
| { status: 'errored'; errors: Error[] }; | { status: 'errored'; errors: Error[] };
const institutionsData = ref<InstitutionData>({ status: 'loading' }); const institutionsData = ref<InstitutionData>({ status: 'loading' });
const plaidOptions = ref<PlaidLinkOptions>({ const plaidOptions = ref<PlaidLinkOptions>({
token: '', token: '',
onSuccess: async (publicToken, metadata) => { onSuccess: async (publicToken, metadata) => {
if (metadata.institution == null) { if (metadata.institution == null) {
alert('Institution information is missing from Plaid response. Please try again.'); alert('Institution information is missing from Plaid response. Please try again.');
return;
}
const connectResult = await institutionService.connect(
publicToken,
metadata.institution.institution_id
);
if (connectResult.err) {
alert(connectResult.val.map(e => e.message).join('\n'));
return;
}
institutionsData.value = { status: 'loading' };
},
onLoad: () => console.log('loaded'),
onExit: () => console.log('exit'),
});
const { open } = usePlaidLink(plaidOptions);
watch(
institutionsData,
async data => {
if (data.status !== 'loading') {
return;
}
const institutionsResult = await institutionService.getInstitutions();
if (institutionsResult.err) {
institutionsData.value = {
status: 'errored',
errors: institutionsResult.val,
};
return;
}
institutionsData.value = {
status: 'loaded',
data: institutionsResult.val,
};
},
{ immediate: true }
);
async function handleAddInstitutionClick() {
const linkTokenResult = await institutionService.createLinkToken();
if (linkTokenResult.err) {
alert(linkTokenResult.val.map(e => e.message).join('\n'));
return; return;
} }
plaidOptions.value = { const connectResult = await institutionService.connect(
...plaidOptions.value, publicToken,
token: linkTokenResult.val.linkToken, metadata.institution.institution_id
);
if (connectResult.err) {
alert(connectResult.val.map(e => e.message).join('\n'));
return;
}
institutionsData.value = { status: 'loading' };
},
onLoad: () => console.log('loaded'),
onExit: () => console.log('exit'),
});
const { open } = usePlaidLink(plaidOptions);
watch(
institutionsData,
async data => {
if (data.status !== 'loading') {
return;
}
const institutionsResult = await institutionService.getInstitutions();
if (institutionsResult.err) {
institutionsData.value = {
status: 'errored',
errors: institutionsResult.val,
};
return;
}
institutionsData.value = {
status: 'loaded',
data: institutionsResult.val,
}; };
},
{ immediate: true }
);
await nextTick(); async function handleAddInstitutionClick() {
const linkTokenResult = await institutionService.createLinkToken();
open(); if (linkTokenResult.err) {
alert(linkTokenResult.val.map(e => e.message).join('\n'));
return;
} }
plaidOptions.value = {
...plaidOptions.value,
token: linkTokenResult.val.linkToken,
};
await nextTick();
open();
}
const targetInstitutionOfAdd = ref<Institution | null>(null);
const availableAccounts = ref<AvailableAccount[]>([]);
const targetAccountOfAdd = ref<AvailableAccount | null>(null);
async function handleAddAccountClick(institution: Institution) {
const accountsResult = await institutionService.getAvailableAccounts(institution.id);
if (accountsResult.err) {
alert('Failed to retrieve accounts to add');
return;
}
availableAccounts.value = accountsResult.val;
targetInstitutionOfAdd.value = institution;
}
async function handleAccountAddClick() {
if (targetAccountOfAdd.value === null) {
return;
}
const request = {
plaidInstitutionId: targetAccountOfAdd.value.providerInstitutionId,
plaidAccountId: targetAccountOfAdd.value.providerId,
plaidAccountName: targetAccountOfAdd.value.providerName,
accountAvailableBalance: targetAccountOfAdd.value.availableBalance,
accountCurrencyCode: targetAccountOfAdd.value.currencyCode,
};
try {
const res = await fetch('/api/accounts', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userStore.user?.token}`,
},
body: JSON.stringify(request),
});
if (res.ok === false) {
alert('Add failed');
return;
}
targetInstitutionOfAdd.value = null;
institutionsData.value = { status: 'loading' };
} catch (error) {
console.error(error);
}
}
</script> </script>
<template> <template>
<div> <div>
<button <button class="add-institution-button" type="button" @click="handleAddInstitutionClick">
class="add-institution-button"
type="button"
@click="handleAddInstitutionClick"
>
Add Institution Add Institution
</button> </button>
</div> </div>
<div <div v-if="institutionsData.status === 'loaded'" class="institutions-container">
v-if="institutionsData.status === 'loaded'" <div v-for="institution in institutionsData.data" v-bind:key="institution.id">
class="institutions-container" <div class="institution-card">
> <div>
<div <div>{{ institution.name }}</div>
class="institution-card" </div>
v-for="institution in institutionsData.data" <div>
v-bind:key="institution.id" <button class="add-account-button" type="button" @click="handleAddAccountClick(institution)">
> Add Account
<div> </button>
<div>{{ institution.name }}</div> </div>
</div> </div>
<div> <div v-if="targetInstitutionOfAdd != null && targetInstitutionOfAdd.id === institution.id">
<button <select v-model="targetAccountOfAdd">
class="add-account-button" <option v-for="account in availableAccounts" v-bind:key="account.providerId" :value="account">
type="button" {{ account.providerName }}
> </option>
Add Account </select>
<button type="button" @click="handleAccountAddClick">
Add
</button> </button>
</div> </div>
<div v-for="accounts in institution.accounts" v-bind:key="accounts.id">
<div>{{ accounts.name }}</div>
</div>
</div> </div>
</div> </div>
<div v-if="institutionsData.status === 'errored'">Failed to load institutions</div> <div v-if="institutionsData.status === 'errored'">Failed to load institutions</div>
</template> </template>
<style scoped> <style scoped>
.institutions-container { .institutions-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
padding: 1rem; padding: 1rem;
} }
.institution-card { .institution-card {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--bg-surface); background: var(--bg-surface);
} }
.institution-card > div { .institution-card>div {
flex: 1; flex: 1;
} }
.institution-card > div:last-of-type { .institution-card>div:last-of-type {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
.add-institution-button, .add-institution-button,
.add-account-button { .add-account-button {
background: var(--bg-element); background: var(--bg-element);
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
</style> </style>