feat(web): handle adding accounts to institution
This commit is contained in:
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user