feat(api,web): update account and institution handling to use provider identifiers and enhance UI components for account addition

This commit is contained in:
Stevan Freeborn
2026-03-11 10:51:28 -05:00
parent 5eda5fbb76
commit e3a75ccbc6
15 changed files with 484 additions and 433 deletions
+6 -6
View File
@@ -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);
+9 -9
View File
@@ -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);
}
+4 -2
View File
@@ -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);
+21 -303
View File
@@ -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();