feat(api,web): update account and institution handling to use provider identifiers and enhance UI components for account addition
This commit is contained in:
@@ -24,9 +24,9 @@ internal static class Endpoint
|
||||
var userId = httpContext.GetUserId();
|
||||
|
||||
var user = await appDbContext.Users
|
||||
.Include(u => u.Institutions.Where(i => i.Metadata is PlaidInstitutionMetadata && ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId))
|
||||
.Include(u => u.Institutions.Where(i => i.Metadata is PlaidInstitutionMetadata && ((PlaidInstitutionMetadata)i.Metadata).PlaidId == request.ProviderInstitutionId))
|
||||
.ThenInclude(i => i.Metadata)
|
||||
.Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.PlaidAccountId))
|
||||
.Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.ProviderAccountId))
|
||||
.ThenInclude(a => a.Metadata)
|
||||
.AsSplitQuery()
|
||||
.SingleOrDefaultAsync(u => u.Id == userId, ct);
|
||||
@@ -40,7 +40,7 @@ internal static class Endpoint
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. No institution connected with the given PlaidInstitutionId was found for the user."],
|
||||
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ internal static class Endpoint
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. The connected institution has no plaid metadata"],
|
||||
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,8 +60,8 @@ internal static class Endpoint
|
||||
}
|
||||
|
||||
var decryptedAccessToken = await encryptor.DecryptAsyncFor(user, plaidInstitutionMetadata.EncryptedAccessToken, ct);
|
||||
var accountMetadata = PlaidAccountMetadata.From(request.PlaidAccountId, request.PlaidAccountName);
|
||||
var account = Account.From(request.PlaidAccountName, accountMetadata);
|
||||
var accountMetadata = PlaidAccountMetadata.From(request.ProviderAccountId, request.ProviderAccountName);
|
||||
var account = Account.From(request.ProviderAccountName, accountMetadata);
|
||||
user.AddAccount(account);
|
||||
user.Institutions.First().AddAccount(account);
|
||||
|
||||
|
||||
@@ -2,27 +2,27 @@ namespace FiscalOS.API.Accounts.Add;
|
||||
|
||||
public record Request : IValidatableObject
|
||||
{
|
||||
public string PlaidInstitutionId { get; init; } = string.Empty;
|
||||
public string PlaidAccountId { get; init; } = string.Empty;
|
||||
public string PlaidAccountName { get; init; } = string.Empty;
|
||||
public string ProviderInstitutionId { get; init; } = string.Empty;
|
||||
public string ProviderAccountId { get; init; } = string.Empty;
|
||||
public string ProviderAccountName { get; init; } = string.Empty;
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PlaidInstitutionId))
|
||||
if (string.IsNullOrWhiteSpace(ProviderInstitutionId))
|
||||
{
|
||||
var fieldName = nameof(PlaidInstitutionId);
|
||||
var fieldName = nameof(ProviderInstitutionId);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(PlaidAccountId))
|
||||
if (string.IsNullOrWhiteSpace(ProviderAccountId))
|
||||
{
|
||||
var fieldName = nameof(PlaidAccountId);
|
||||
var fieldName = nameof(ProviderAccountId);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(PlaidAccountName))
|
||||
if (string.IsNullOrWhiteSpace(ProviderAccountName))
|
||||
{
|
||||
var fieldName = nameof(PlaidAccountName);
|
||||
var fieldName = nameof(ProviderAccountName);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import CircleSpinner from '@/components/CircleSpinner.vue';
|
||||
import type { AvailableAccount } from '@/services/institutionService';
|
||||
import { ref, watchEffect } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
availableAccounts: AvailableAccount[];
|
||||
isSubmitting: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [account: AvailableAccount];
|
||||
}>();
|
||||
|
||||
const selectedAccount = ref<AvailableAccount | null>(null);
|
||||
const selectRef = ref<HTMLSelectElement | null>(null);
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.availableAccounts.length > 0) {
|
||||
selectRef.value?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
if (selectedAccount.value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', selectedAccount.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="add-account-form"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<label for="account-select">Select account to add:</label>
|
||||
<select
|
||||
id="account-select"
|
||||
ref="selectRef"
|
||||
v-model="selectedAccount"
|
||||
>
|
||||
<option
|
||||
:value="null"
|
||||
disabled
|
||||
hidden
|
||||
>
|
||||
Select an account
|
||||
</option>
|
||||
<option
|
||||
v-for="account in availableAccounts"
|
||||
:key="account.providerId"
|
||||
:value="account"
|
||||
>
|
||||
{{ account.providerName }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="add-account-button"
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
<CircleSpinner v-if="isSubmitting" />
|
||||
<span v-else>Add</span>
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.add-account-form {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import AddAccountForm from '@/components/AddAccountForm.vue';
|
||||
import CircleSpinner from '@/components/CircleSpinner.vue';
|
||||
import { useAccountService } from '@/composables/useAccountService';
|
||||
import { useInstitutionService } from '@/composables/useInstitutionService';
|
||||
import type { AvailableAccount, Institution } from '@/services/institutionService';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
institution: Institution;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
accountAdded: [];
|
||||
}>();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const institutionService = useInstitutionService(userStore);
|
||||
const accountService = useAccountService(userStore);
|
||||
|
||||
const loading = ref(false);
|
||||
const addingAccount = ref(false);
|
||||
const availableAccounts = ref<AvailableAccount[]>([]);
|
||||
const showAddForm = ref(false);
|
||||
|
||||
async function handleAddAccountClick() {
|
||||
loading.value = true;
|
||||
const getAccountsResult = await institutionService.getAvailableAccounts(props.institution.id);
|
||||
loading.value = false;
|
||||
|
||||
if (getAccountsResult.err) {
|
||||
alert('Failed to retrieve accounts to add');
|
||||
return;
|
||||
}
|
||||
|
||||
const notAlreadyAddedAccounts = getAccountsResult.val.filter(a => {
|
||||
return props.institution.accounts.some(ac => ac.providerId === a.providerId) === false;
|
||||
});
|
||||
|
||||
availableAccounts.value = notAlreadyAddedAccounts;
|
||||
showAddForm.value = true;
|
||||
}
|
||||
|
||||
async function handleFormSubmit(account: AvailableAccount) {
|
||||
addingAccount.value = true;
|
||||
|
||||
try {
|
||||
const addAccountResult = await accountService.add(
|
||||
account.providerInstitutionId,
|
||||
account.providerId,
|
||||
account.providerName
|
||||
);
|
||||
|
||||
if (addAccountResult.err) {
|
||||
alert(addAccountResult.val.map(e => e.message).join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
showAddForm.value = false;
|
||||
emit('accountAdded');
|
||||
} finally {
|
||||
addingAccount.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="institution-container">
|
||||
<div class="institution-card">
|
||||
<div>
|
||||
<div>{{ institution.name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="add-account-button"
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
@click="handleAddAccountClick"
|
||||
>
|
||||
<CircleSpinner v-if="loading" />
|
||||
<span v-else>Add Account</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounts-list">
|
||||
<div
|
||||
class="account-row"
|
||||
v-for="account in institution.accounts"
|
||||
:key="account.id"
|
||||
>
|
||||
<div class="account-card">{{ account.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="add-account-card"
|
||||
v-if="showAddForm"
|
||||
>
|
||||
<AddAccountForm
|
||||
:availableAccounts="availableAccounts"
|
||||
:isSubmitting="addingAccount"
|
||||
@submit="handleFormSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.institution-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.institution-card,
|
||||
.account-card,
|
||||
.add-account-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.institution-card,
|
||||
.account-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.institution-card > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.institution-card > div:last-of-type {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.institution-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -0.25rem;
|
||||
transform: translateY(-50%);
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.institution-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -1px;
|
||||
height: calc(50% + 0.5rem);
|
||||
width: 2px;
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
|
||||
.add-account-card {
|
||||
margin-left: 2rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.accounts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.account-row {
|
||||
position: relative;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.account-row::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -1px;
|
||||
height: calc(100% + 0.5rem);
|
||||
width: 2px;
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
|
||||
.account-row:not(:first-child)::before {
|
||||
top: -0.5rem;
|
||||
height: calc(100% + 1rem);
|
||||
}
|
||||
|
||||
.account-row:last-child::before {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.account-row:not(:first-child):last-child::before {
|
||||
top: -0.5rem;
|
||||
height: calc(50% + 0.5rem);
|
||||
}
|
||||
|
||||
.account-row::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -1px;
|
||||
width: calc(2rem + 1px);
|
||||
height: 2px;
|
||||
background: var(--brand-primary);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.account-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -0.25rem;
|
||||
transform: translateY(-50%);
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { AccountServiceFactoryKey } from '@/services/accountService';
|
||||
import type { UserStore } from '@/stores/userStore';
|
||||
import { useService } from './useService';
|
||||
|
||||
export function useAccountService(store: UserStore) {
|
||||
return useService(store, AccountServiceFactoryKey);
|
||||
}
|
||||
@@ -1,27 +1,7 @@
|
||||
import { AuthServiceFactoryKey } from "@/services/authService";
|
||||
import { ClientConfig, ClientFactoryKey } from "@/services/client";
|
||||
import type { UserStore } from "@/stores/userStore";
|
||||
import { inject } from "vue";
|
||||
import { AuthServiceFactoryKey } from '@/services/authService';
|
||||
import type { UserStore } from '@/stores/userStore';
|
||||
import { useService } from './useService';
|
||||
|
||||
export function useAuthService(store: UserStore) {
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const authServiceFactory = inject(AuthServiceFactoryKey);
|
||||
|
||||
if (clientFactory === undefined) {
|
||||
throw new Error("Failed to inject client factory.")
|
||||
}
|
||||
|
||||
if (authServiceFactory === undefined) {
|
||||
throw new Error("Failed to inject auth service factory.")
|
||||
}
|
||||
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${store.user?.token}`},
|
||||
true,
|
||||
store.refreshAccessToken
|
||||
);
|
||||
const client = clientFactory.create(clientConfig);
|
||||
const authService = authServiceFactory.create(client);
|
||||
|
||||
return authService;
|
||||
return useService(store, AuthServiceFactoryKey);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
import { ClientConfig, ClientFactoryKey } from "@/services/client";
|
||||
import { InstituionServiceFactoryKey } from "@/services/institutionService";
|
||||
import type { UserStore } from "@/stores/userStore";
|
||||
import { inject } from "vue";
|
||||
import { InstitutionServiceFactoryKey } from '@/services/institutionService';
|
||||
import type { UserStore } from '@/stores/userStore';
|
||||
import { useService } from './useService';
|
||||
|
||||
export function useInstitutionService(store: UserStore) {
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const institutionServiceFactory = inject(InstituionServiceFactoryKey);
|
||||
|
||||
if (clientFactory === undefined) {
|
||||
throw new Error("Failed to inject client factory.")
|
||||
}
|
||||
|
||||
if (institutionServiceFactory === undefined) {
|
||||
throw new Error("Failed to inject institution service factory.")
|
||||
}
|
||||
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${store.user?.token}`},
|
||||
true,
|
||||
store.refreshAccessToken
|
||||
);
|
||||
const client = clientFactory.create(clientConfig);
|
||||
const institutionService = institutionServiceFactory.create(client);
|
||||
|
||||
return institutionService;
|
||||
return useService(store, InstitutionServiceFactoryKey);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { InjectionKey } from 'vue';
|
||||
import { inject } from 'vue';
|
||||
import { ClientConfig, ClientFactoryKey, type IClient } from '@/services/client';
|
||||
import type { UserStore } from '@/stores/userStore';
|
||||
|
||||
export interface IServiceFactory<TService> {
|
||||
create: (client: IClient) => TService;
|
||||
}
|
||||
|
||||
export function useService<TService>(
|
||||
store: UserStore,
|
||||
serviceFactoryKey: InjectionKey<IServiceFactory<TService>>
|
||||
): TService {
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const serviceFactory = inject(serviceFactoryKey);
|
||||
|
||||
if (clientFactory === undefined) {
|
||||
throw new Error('Failed to inject client factory.');
|
||||
}
|
||||
|
||||
if (serviceFactory === undefined) {
|
||||
throw new Error('Failed to inject service factory.');
|
||||
}
|
||||
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${store.user?.token}` },
|
||||
true,
|
||||
store.refreshAccessToken
|
||||
);
|
||||
|
||||
const client = clientFactory.create(clientConfig);
|
||||
return serviceFactory.create(client);
|
||||
}
|
||||
@@ -1,27 +1,7 @@
|
||||
import { ClientConfig, ClientFactoryKey } from '@/services/client';
|
||||
import { TransactionServiceFactoryKey } from '@/services/transactionService';
|
||||
import type { UserStore } from '@/stores/userStore';
|
||||
import { inject } from 'vue';
|
||||
import { useService } from './useService';
|
||||
|
||||
export function useTransactionService(store: UserStore) {
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const transactionServiceFactory = inject(TransactionServiceFactoryKey);
|
||||
|
||||
if (clientFactory === undefined) {
|
||||
throw new Error('Failed to inject client factory.');
|
||||
}
|
||||
|
||||
if (transactionServiceFactory === undefined) {
|
||||
throw new Error('Failed to inject transaction service factory.');
|
||||
}
|
||||
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${store.user?.token}` },
|
||||
true,
|
||||
store.refreshAccessToken
|
||||
);
|
||||
const client = clientFactory.create(clientConfig);
|
||||
const transactionService = transactionServiceFactory.create(client);
|
||||
|
||||
return transactionService;
|
||||
return useService(store, TransactionServiceFactoryKey);
|
||||
}
|
||||
|
||||
@@ -8,20 +8,22 @@ import router from './router';
|
||||
import { ClientFactory, ClientFactoryKey } from './services/client';
|
||||
import { AuthServiceFactory, AuthServiceFactoryKey } from './services/authService';
|
||||
import {
|
||||
InstituionServiceFactoryKey,
|
||||
InstitutionServiceFactoryKey,
|
||||
InstitutionServiceFactory,
|
||||
} from './services/institutionService';
|
||||
import {
|
||||
TransactionServiceFactory,
|
||||
TransactionServiceFactoryKey,
|
||||
} from './services/transactionService';
|
||||
import { AccountServiceFactory, AccountServiceFactoryKey } from './services/accountService';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.provide(ClientFactoryKey, new ClientFactory());
|
||||
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
|
||||
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
|
||||
app.provide(InstitutionServiceFactoryKey, new InstitutionServiceFactory());
|
||||
app.provide(TransactionServiceFactoryKey, new TransactionServiceFactory());
|
||||
app.provide(AccountServiceFactoryKey, new AccountServiceFactory());
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { InjectionKey } from 'vue';
|
||||
import { ClientRequestWithBody, type IClient } from './client';
|
||||
import { Err, Ok, type Result } from 'ts-results';
|
||||
|
||||
type AccountServiceFactoryKeyType = InjectionKey<IAccountServiceFactory>;
|
||||
|
||||
export const AccountServiceFactoryKey: AccountServiceFactoryKeyType =
|
||||
Symbol('AccountServiceFactory');
|
||||
|
||||
export interface IAccountServiceFactory {
|
||||
create: (client: IClient) => IAccountService;
|
||||
}
|
||||
|
||||
export class AccountServiceFactory implements IAccountServiceFactory {
|
||||
create(client: IClient): IAccountService {
|
||||
return new AccountService(client);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IAccountService {
|
||||
add: (
|
||||
providerInstitutionId: string,
|
||||
providerAccountId: string,
|
||||
providerAccountName: string
|
||||
) => Promise<Result<boolean, Error[]>>;
|
||||
}
|
||||
|
||||
export class AccountService implements IAccountService {
|
||||
private readonly client: IClient;
|
||||
private readonly endpoints = {
|
||||
add: '/api/accounts',
|
||||
};
|
||||
|
||||
constructor(client: IClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async add(providerInstitutionId: string, providerAccountId: string, providerAccountName: string) {
|
||||
const request = new ClientRequestWithBody(this.endpoints.add, undefined, {
|
||||
providerInstitutionId,
|
||||
providerAccountId,
|
||||
providerAccountName,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await this.client.post(request);
|
||||
|
||||
if (res.ok === false) {
|
||||
return Err([new Error('Failed to add account.')]);
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return Err([new Error('Failed to add account.')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import { ClientRequest, ClientRequestWithBody, type IClient } from './client';
|
||||
|
||||
type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>;
|
||||
|
||||
export const InstituionServiceFactoryKey: InstitutionServiceFactoryKeyType =
|
||||
Symbol('AuthServiceFactory');
|
||||
export const InstitutionServiceFactoryKey: InstitutionServiceFactoryKeyType = Symbol(
|
||||
'InstitutionServiceFactory'
|
||||
);
|
||||
|
||||
export interface IInstitutionServiceFactory {
|
||||
create: (client: IClient) => IInstitutionService;
|
||||
|
||||
@@ -4,8 +4,9 @@ import { Err, Ok, type Result } from 'ts-results';
|
||||
|
||||
type TransactionServiceFactoryKeyType = InjectionKey<ITransactionServiceFactory>;
|
||||
|
||||
export const TransactionServiceFactoryKey: TransactionServiceFactoryKeyType =
|
||||
Symbol('AuthServiceFactory');
|
||||
export const TransactionServiceFactoryKey: TransactionServiceFactoryKeyType = Symbol(
|
||||
'TransactionServiceFactory'
|
||||
);
|
||||
|
||||
export interface ITransactionServiceFactory {
|
||||
create: (client: IClient) => ITransactionService;
|
||||
@@ -40,13 +41,13 @@ export class TransactionService implements ITransactionService {
|
||||
const request = new ClientRequest(url);
|
||||
|
||||
try {
|
||||
const response = await this.client.get(request);
|
||||
const res = await this.client.get(request);
|
||||
|
||||
if (response.ok === false) {
|
||||
if (res.ok === false) {
|
||||
return Err([new Error('Failed to retrieve transactions.')]);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await res.json();
|
||||
return Ok(data as Page<Transaction>);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import CircleSpinner from '@/components/CircleSpinner.vue';
|
||||
import InstitutionCard from '@/components/InstitutionCard.vue';
|
||||
import { useInstitutionService } from '@/composables/useInstitutionService';
|
||||
import type { AvailableAccount, Institution } from '@/services/institutionService';
|
||||
import type { Institution } from '@/services/institutionService';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import {
|
||||
usePlaidLink,
|
||||
@@ -11,28 +11,10 @@
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
|
||||
type InstitutionData =
|
||||
| {
|
||||
status: 'loading';
|
||||
}
|
||||
| { status: 'loading' }
|
||||
| { status: 'loaded'; data: Institution[] }
|
||||
| { status: 'errored'; errors: Error[] };
|
||||
|
||||
const institutionsData = ref<InstitutionData>({ status: 'loading' });
|
||||
const targetInstitutionOfAdd = ref<Institution | null>(null);
|
||||
const availableAccounts = ref<AvailableAccount[]>([]);
|
||||
const targetAccountOfAdd = ref<AvailableAccount | null>(null);
|
||||
const loadingInstitutionId = ref<string | null>(null);
|
||||
const accountSelectRefs = ref<HTMLSelectElement[]>([]);
|
||||
const addingAccount = ref(false);
|
||||
const plaidOptions = ref<PlaidLinkOptions>({
|
||||
token: '',
|
||||
onSuccess: handleSuccess,
|
||||
});
|
||||
|
||||
const userStore = useUserStore();
|
||||
const institutionService = useInstitutionService(userStore);
|
||||
const { open } = usePlaidLink(plaidOptions);
|
||||
|
||||
async function handleSuccess(publicToken: string, metadata: PlaidLinkOnSuccessMetadata) {
|
||||
if (metadata.institution == null) {
|
||||
alert('Institution information is missing from Plaid response. Please try again.');
|
||||
@@ -52,6 +34,18 @@
|
||||
institutionsData.value = { status: 'loading' };
|
||||
}
|
||||
|
||||
const plaidOptions = ref<PlaidLinkOptions>({
|
||||
token: '',
|
||||
onSuccess: handleSuccess,
|
||||
});
|
||||
|
||||
const { open } = usePlaidLink(plaidOptions);
|
||||
|
||||
const institutionsData = ref<InstitutionData>({ status: 'loading' });
|
||||
|
||||
const userStore = useUserStore();
|
||||
const institutionService = useInstitutionService(userStore);
|
||||
|
||||
async function institutionsDataWatcher(data: InstitutionData) {
|
||||
if (data.status !== 'loading') {
|
||||
return;
|
||||
@@ -60,18 +54,11 @@
|
||||
const institutionsResult = await institutionService.getInstitutions();
|
||||
|
||||
if (institutionsResult.err) {
|
||||
institutionsData.value = {
|
||||
status: 'errored',
|
||||
errors: institutionsResult.val,
|
||||
};
|
||||
|
||||
institutionsData.value = { status: 'errored', errors: institutionsResult.val };
|
||||
return;
|
||||
}
|
||||
|
||||
institutionsData.value = {
|
||||
status: 'loaded',
|
||||
data: institutionsResult.val,
|
||||
};
|
||||
institutionsData.value = { status: 'loaded', data: institutionsResult.val };
|
||||
}
|
||||
|
||||
watch(institutionsData, institutionsDataWatcher, { immediate: true });
|
||||
@@ -93,70 +80,6 @@
|
||||
|
||||
open();
|
||||
}
|
||||
|
||||
async function handleAddAccountClick(institution: Institution) {
|
||||
loadingInstitutionId.value = institution.id;
|
||||
const accountsResult = await institutionService.getAvailableAccounts(institution.id);
|
||||
loadingInstitutionId.value = null;
|
||||
|
||||
if (accountsResult.err) {
|
||||
alert('Failed to retrieve accounts to add');
|
||||
return;
|
||||
}
|
||||
|
||||
const notAlreadyAddedAccounts = accountsResult.val.filter(a => {
|
||||
return institution.accounts.some(ac => ac.providerId === a.providerId) === false;
|
||||
});
|
||||
|
||||
availableAccounts.value = notAlreadyAddedAccounts;
|
||||
targetInstitutionOfAdd.value = institution;
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (institutionsData.value.status === 'loaded') {
|
||||
const index = institutionsData.value.data.findIndex(i => i.id === institution.id);
|
||||
accountSelectRefs.value[index]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAccountAddClick() {
|
||||
if (targetAccountOfAdd.value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
addingAccount.value = true;
|
||||
|
||||
const request = {
|
||||
plaidInstitutionId: targetAccountOfAdd.value.providerInstitutionId,
|
||||
plaidAccountId: targetAccountOfAdd.value.providerId,
|
||||
plaidAccountName: targetAccountOfAdd.value.providerName,
|
||||
};
|
||||
|
||||
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');
|
||||
addingAccount.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
targetInstitutionOfAdd.value = null;
|
||||
addingAccount.value = false;
|
||||
institutionsData.value = { status: 'loading' };
|
||||
} catch (error) {
|
||||
addingAccount.value = false;
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -174,74 +97,12 @@
|
||||
v-if="institutionsData.status === 'loaded'"
|
||||
class="institutions-container"
|
||||
>
|
||||
<div
|
||||
class="institution-container"
|
||||
<InstitutionCard
|
||||
v-for="institution in institutionsData.data"
|
||||
:key="institution.id"
|
||||
>
|
||||
<div class="institution-card">
|
||||
<div>
|
||||
<div>{{ institution.name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="add-account-button"
|
||||
type="button"
|
||||
:disabled="loadingInstitutionId === institution.id"
|
||||
@click="handleAddAccountClick(institution)"
|
||||
>
|
||||
<CircleSpinner v-if="loadingInstitutionId === institution.id" />
|
||||
<span v-else>Add Account</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounts-list">
|
||||
<div
|
||||
class="account-row"
|
||||
v-for="accounts in institution.accounts"
|
||||
:key="accounts.id"
|
||||
>
|
||||
<div class="account-card">{{ accounts.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="add-account-card"
|
||||
v-if="targetInstitutionOfAdd != null && targetInstitutionOfAdd.id === institution.id"
|
||||
>
|
||||
<div class="select-account-container">
|
||||
<label for="account-select">Select account to add:</label>
|
||||
<select
|
||||
id="account-select"
|
||||
ref="accountSelectRefs"
|
||||
v-model="targetAccountOfAdd"
|
||||
>
|
||||
<option
|
||||
:value="null"
|
||||
disabled
|
||||
hidden
|
||||
>
|
||||
Select an account
|
||||
</option>
|
||||
<option
|
||||
v-for="account in availableAccounts"
|
||||
:key="account.providerId"
|
||||
:value="account"
|
||||
>
|
||||
{{ account.providerName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
class="add-account-button"
|
||||
type="button"
|
||||
:disabled="addingAccount"
|
||||
@click="handleAccountAddClick"
|
||||
>
|
||||
<CircleSpinner v-if="addingAccount" />
|
||||
<span v-else>Add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
:institution="institution"
|
||||
@accountAdded="institutionsData = { status: 'loading' }"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="institutionsData.status === 'errored'">Failed to load institutions</div>
|
||||
</template>
|
||||
@@ -259,147 +120,4 @@
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.institution-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.institution-card,
|
||||
.account-card,
|
||||
.add-account-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.institution-card,
|
||||
.account-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.institution-card > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.institution-card > div:last-of-type {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.institution-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -0.25rem;
|
||||
transform: translateY(-50%);
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.institution-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -1px;
|
||||
height: calc(50% + 0.5rem);
|
||||
width: 2px;
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
|
||||
.add-account-card {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
.add-account-card {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.accounts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.account-row {
|
||||
position: relative;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.account-row::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -1px;
|
||||
height: calc(100% + 0.5rem);
|
||||
width: 2px;
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
|
||||
.account-row:not(:first-child)::before {
|
||||
top: -0.5rem;
|
||||
height: calc(100% + 1rem);
|
||||
}
|
||||
|
||||
.account-row:last-child::before {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.account-row:not(:first-child):last-child::before {
|
||||
top: -0.5rem;
|
||||
height: calc(50% + 0.5rem);
|
||||
}
|
||||
|
||||
.account-row::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -1px;
|
||||
width: calc(2rem + 1px);
|
||||
height: 2px;
|
||||
background: var(--brand-primary);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.account-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -0.25rem;
|
||||
transform: translateY(-50%);
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-primary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.select-account-container {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.select-account-container > select {
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.add-institution-button,
|
||||
.add-account-button {
|
||||
background: var(--bg-element);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.add-account-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using FiscalOS.API.Accounts.Add;
|
||||
using FiscalOS.Core.Queuing;
|
||||
using FiscalOS.Infra.Transactions.Plaid;
|
||||
|
||||
@@ -31,9 +32,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
|
||||
["PlaidAccountId"] = ["The PlaidAccountId field is required."],
|
||||
["PlaidAccountName"] = ["The PlaidAccountName field is required."],
|
||||
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is required."],
|
||||
[nameof(Request.ProviderAccountId)] = [$"The {nameof(Request.ProviderAccountId)} field is required."],
|
||||
[nameof(Request.ProviderAccountName)] = [$"The {nameof(Request.ProviderAccountName)} field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,9 +46,8 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidAccountId = "accountId",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
providerAccountId = "accountId",
|
||||
providerAccountName = "Some Account",
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -55,7 +55,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
|
||||
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,9 +67,8 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "institutionId",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
providerInstitutionId = "institutionId",
|
||||
providerAccountName = "Some Account",
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -77,7 +76,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidAccountId"] = ["The PlaidAccountId field is required."],
|
||||
[nameof(Request.ProviderAccountId)] = [$"The {nameof(Request.ProviderAccountId)} field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,9 +88,8 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "institutionId",
|
||||
plaidAccountId = "accountId",
|
||||
accountCurrencyCode = "USD",
|
||||
providerInstitutionId = "institutionId",
|
||||
providerAccountId = "accountId",
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -99,7 +97,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidAccountName"] = ["The PlaidAccountName field is required."],
|
||||
[nameof(Request.ProviderAccountName)] = [$"The {nameof(Request.ProviderAccountName)} field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,10 +109,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "id",
|
||||
plaidAccountId = "id",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
providerInstitutionId = "id",
|
||||
providerAccountId = "id",
|
||||
providerAccountName = "Some Account",
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -145,10 +142,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "id",
|
||||
plaidAccountId = "id",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
providerInstitutionId = "id",
|
||||
providerAccountId = "id",
|
||||
providerAccountName = "Some Account",
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -156,7 +152,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. No institution connected with the given PlaidInstitutionId was found for the user."],
|
||||
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is invalid."],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -199,10 +195,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
|
||||
plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
|
||||
plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
|
||||
accountCurrencyCode = "USD",
|
||||
providerInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
|
||||
providerAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
|
||||
providerAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -252,20 +247,15 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
|
||||
var newAccountId = "newAccountId";
|
||||
var newAccountName = "New Account";
|
||||
var expectedBalance = 100;
|
||||
var expectedCurrencyCode = "USD";
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
|
||||
plaidAccountId = newAccountId,
|
||||
plaidAccountName = newAccountName,
|
||||
accountCurrentBalance = expectedBalance,
|
||||
accountAvailableBalance = expectedBalance,
|
||||
accountCurrencyCode = expectedCurrencyCode,
|
||||
providerInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
|
||||
providerAccountId = newAccountId,
|
||||
providerAccountName = newAccountName,
|
||||
})
|
||||
.Build();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user