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 userId = httpContext.GetUserId();
|
||||||
|
|
||||||
var user = await appDbContext.Users
|
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)
|
.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)
|
.ThenInclude(a => a.Metadata)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.SingleOrDefaultAsync(u => u.Id == userId, ct);
|
.SingleOrDefaultAsync(u => u.Id == userId, ct);
|
||||||
@@ -40,7 +40,7 @@ internal static class Endpoint
|
|||||||
{
|
{
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
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[]>
|
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 decryptedAccessToken = await encryptor.DecryptAsyncFor(user, plaidInstitutionMetadata.EncryptedAccessToken, ct);
|
||||||
var accountMetadata = PlaidAccountMetadata.From(request.PlaidAccountId, request.PlaidAccountName);
|
var accountMetadata = PlaidAccountMetadata.From(request.ProviderAccountId, request.ProviderAccountName);
|
||||||
var account = Account.From(request.PlaidAccountName, accountMetadata);
|
var account = Account.From(request.ProviderAccountName, accountMetadata);
|
||||||
user.AddAccount(account);
|
user.AddAccount(account);
|
||||||
user.Institutions.First().AddAccount(account);
|
user.Institutions.First().AddAccount(account);
|
||||||
|
|
||||||
|
|||||||
@@ -2,27 +2,27 @@ namespace FiscalOS.API.Accounts.Add;
|
|||||||
|
|
||||||
public record Request : IValidatableObject
|
public record Request : IValidatableObject
|
||||||
{
|
{
|
||||||
public string PlaidInstitutionId { get; init; } = string.Empty;
|
public string ProviderInstitutionId { get; init; } = string.Empty;
|
||||||
public string PlaidAccountId { get; init; } = string.Empty;
|
public string ProviderAccountId { get; init; } = string.Empty;
|
||||||
public string PlaidAccountName { get; init; } = string.Empty;
|
public string ProviderAccountName { get; init; } = string.Empty;
|
||||||
|
|
||||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
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]);
|
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]);
|
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]);
|
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 { AuthServiceFactoryKey } from '@/services/authService';
|
||||||
import { ClientConfig, ClientFactoryKey } from "@/services/client";
|
import type { UserStore } from '@/stores/userStore';
|
||||||
import type { UserStore } from "@/stores/userStore";
|
import { useService } from './useService';
|
||||||
import { inject } from "vue";
|
|
||||||
|
|
||||||
export function useAuthService(store: UserStore) {
|
export function useAuthService(store: UserStore) {
|
||||||
const clientFactory = inject(ClientFactoryKey);
|
return useService(store, AuthServiceFactoryKey);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,7 @@
|
|||||||
import { ClientConfig, ClientFactoryKey } from "@/services/client";
|
import { InstitutionServiceFactoryKey } from '@/services/institutionService';
|
||||||
import { InstituionServiceFactoryKey } from "@/services/institutionService";
|
import type { UserStore } from '@/stores/userStore';
|
||||||
import type { UserStore } from "@/stores/userStore";
|
import { useService } from './useService';
|
||||||
import { inject } from "vue";
|
|
||||||
|
|
||||||
export function useInstitutionService(store: UserStore) {
|
export function useInstitutionService(store: UserStore) {
|
||||||
const clientFactory = inject(ClientFactoryKey);
|
return useService(store, InstitutionServiceFactoryKey);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { TransactionServiceFactoryKey } from '@/services/transactionService';
|
||||||
import type { UserStore } from '@/stores/userStore';
|
import type { UserStore } from '@/stores/userStore';
|
||||||
import { inject } from 'vue';
|
import { useService } from './useService';
|
||||||
|
|
||||||
export function useTransactionService(store: UserStore) {
|
export function useTransactionService(store: UserStore) {
|
||||||
const clientFactory = inject(ClientFactoryKey);
|
return useService(store, TransactionServiceFactoryKey);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,20 +8,22 @@ import router from './router';
|
|||||||
import { ClientFactory, ClientFactoryKey } from './services/client';
|
import { ClientFactory, ClientFactoryKey } from './services/client';
|
||||||
import { AuthServiceFactory, AuthServiceFactoryKey } from './services/authService';
|
import { AuthServiceFactory, AuthServiceFactoryKey } from './services/authService';
|
||||||
import {
|
import {
|
||||||
InstituionServiceFactoryKey,
|
InstitutionServiceFactoryKey,
|
||||||
InstitutionServiceFactory,
|
InstitutionServiceFactory,
|
||||||
} from './services/institutionService';
|
} from './services/institutionService';
|
||||||
import {
|
import {
|
||||||
TransactionServiceFactory,
|
TransactionServiceFactory,
|
||||||
TransactionServiceFactoryKey,
|
TransactionServiceFactoryKey,
|
||||||
} from './services/transactionService';
|
} from './services/transactionService';
|
||||||
|
import { AccountServiceFactory, AccountServiceFactoryKey } from './services/accountService';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
app.provide(ClientFactoryKey, new ClientFactory());
|
app.provide(ClientFactoryKey, new ClientFactory());
|
||||||
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
|
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
|
||||||
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
|
app.provide(InstitutionServiceFactoryKey, new InstitutionServiceFactory());
|
||||||
app.provide(TransactionServiceFactoryKey, new TransactionServiceFactory());
|
app.provide(TransactionServiceFactoryKey, new TransactionServiceFactory());
|
||||||
|
app.provide(AccountServiceFactoryKey, new AccountServiceFactory());
|
||||||
|
|
||||||
app.use(createPinia());
|
app.use(createPinia());
|
||||||
app.use(router);
|
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>;
|
type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>;
|
||||||
|
|
||||||
export const InstituionServiceFactoryKey: InstitutionServiceFactoryKeyType =
|
export const InstitutionServiceFactoryKey: InstitutionServiceFactoryKeyType = Symbol(
|
||||||
Symbol('AuthServiceFactory');
|
'InstitutionServiceFactory'
|
||||||
|
);
|
||||||
|
|
||||||
export interface IInstitutionServiceFactory {
|
export interface IInstitutionServiceFactory {
|
||||||
create: (client: IClient) => IInstitutionService;
|
create: (client: IClient) => IInstitutionService;
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { Err, Ok, type Result } from 'ts-results';
|
|||||||
|
|
||||||
type TransactionServiceFactoryKeyType = InjectionKey<ITransactionServiceFactory>;
|
type TransactionServiceFactoryKeyType = InjectionKey<ITransactionServiceFactory>;
|
||||||
|
|
||||||
export const TransactionServiceFactoryKey: TransactionServiceFactoryKeyType =
|
export const TransactionServiceFactoryKey: TransactionServiceFactoryKeyType = Symbol(
|
||||||
Symbol('AuthServiceFactory');
|
'TransactionServiceFactory'
|
||||||
|
);
|
||||||
|
|
||||||
export interface ITransactionServiceFactory {
|
export interface ITransactionServiceFactory {
|
||||||
create: (client: IClient) => ITransactionService;
|
create: (client: IClient) => ITransactionService;
|
||||||
@@ -40,13 +41,13 @@ export class TransactionService implements ITransactionService {
|
|||||||
const request = new ClientRequest(url);
|
const request = new ClientRequest(url);
|
||||||
|
|
||||||
try {
|
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.')]);
|
return Err([new Error('Failed to retrieve transactions.')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await res.json();
|
||||||
return Ok(data as Page<Transaction>);
|
return Ok(data as Page<Transaction>);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import CircleSpinner from '@/components/CircleSpinner.vue';
|
import InstitutionCard from '@/components/InstitutionCard.vue';
|
||||||
import { useInstitutionService } from '@/composables/useInstitutionService';
|
import { useInstitutionService } from '@/composables/useInstitutionService';
|
||||||
import type { AvailableAccount, Institution } from '@/services/institutionService';
|
import type { Institution } from '@/services/institutionService';
|
||||||
import { useUserStore } from '@/stores/userStore';
|
import { useUserStore } from '@/stores/userStore';
|
||||||
import {
|
import {
|
||||||
usePlaidLink,
|
usePlaidLink,
|
||||||
@@ -11,28 +11,10 @@
|
|||||||
import { nextTick, ref, watch } from 'vue';
|
import { nextTick, ref, watch } from 'vue';
|
||||||
|
|
||||||
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 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) {
|
async function handleSuccess(publicToken: string, metadata: PlaidLinkOnSuccessMetadata) {
|
||||||
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.');
|
||||||
@@ -52,6 +34,18 @@
|
|||||||
institutionsData.value = { status: 'loading' };
|
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) {
|
async function institutionsDataWatcher(data: InstitutionData) {
|
||||||
if (data.status !== 'loading') {
|
if (data.status !== 'loading') {
|
||||||
return;
|
return;
|
||||||
@@ -60,18 +54,11 @@
|
|||||||
const institutionsResult = await institutionService.getInstitutions();
|
const institutionsResult = await institutionService.getInstitutions();
|
||||||
|
|
||||||
if (institutionsResult.err) {
|
if (institutionsResult.err) {
|
||||||
institutionsData.value = {
|
institutionsData.value = { status: 'errored', errors: institutionsResult.val };
|
||||||
status: 'errored',
|
|
||||||
errors: institutionsResult.val,
|
|
||||||
};
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
institutionsData.value = {
|
institutionsData.value = { status: 'loaded', data: institutionsResult.val };
|
||||||
status: 'loaded',
|
|
||||||
data: institutionsResult.val,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(institutionsData, institutionsDataWatcher, { immediate: true });
|
watch(institutionsData, institutionsDataWatcher, { immediate: true });
|
||||||
@@ -93,70 +80,6 @@
|
|||||||
|
|
||||||
open();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -174,74 +97,12 @@
|
|||||||
v-if="institutionsData.status === 'loaded'"
|
v-if="institutionsData.status === 'loaded'"
|
||||||
class="institutions-container"
|
class="institutions-container"
|
||||||
>
|
>
|
||||||
<div
|
<InstitutionCard
|
||||||
class="institution-container"
|
|
||||||
v-for="institution in institutionsData.data"
|
v-for="institution in institutionsData.data"
|
||||||
:key="institution.id"
|
:key="institution.id"
|
||||||
>
|
:institution="institution"
|
||||||
<div class="institution-card">
|
@accountAdded="institutionsData = { status: 'loading' }"
|
||||||
<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>
|
|
||||||
</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>
|
||||||
@@ -259,147 +120,4 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 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>
|
</style>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using FiscalOS.API.Accounts.Add;
|
||||||
using FiscalOS.Core.Queuing;
|
using FiscalOS.Core.Queuing;
|
||||||
using FiscalOS.Infra.Transactions.Plaid;
|
using FiscalOS.Infra.Transactions.Plaid;
|
||||||
|
|
||||||
@@ -31,9 +32,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
|
|
||||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||||
{
|
{
|
||||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
|
[nameof(Request.ProviderInstitutionId)] = [$"The {nameof(Request.ProviderInstitutionId)} field is required."],
|
||||||
["PlaidAccountId"] = ["The PlaidAccountId field is required."],
|
[nameof(Request.ProviderAccountId)] = [$"The {nameof(Request.ProviderAccountId)} field is required."],
|
||||||
["PlaidAccountName"] = ["The PlaidAccountName 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())
|
.WithUserId(Guid.NewGuid())
|
||||||
.WithBody(new
|
.WithBody(new
|
||||||
{
|
{
|
||||||
plaidAccountId = "accountId",
|
providerAccountId = "accountId",
|
||||||
plaidAccountName = "Some Account",
|
providerAccountName = "Some Account",
|
||||||
accountCurrencyCode = "USD",
|
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
|
|
||||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
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())
|
.WithUserId(Guid.NewGuid())
|
||||||
.WithBody(new
|
.WithBody(new
|
||||||
{
|
{
|
||||||
plaidInstitutionId = "institutionId",
|
providerInstitutionId = "institutionId",
|
||||||
plaidAccountName = "Some Account",
|
providerAccountName = "Some Account",
|
||||||
accountCurrencyCode = "USD",
|
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
@@ -77,7 +76,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
|
|
||||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
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())
|
.WithUserId(Guid.NewGuid())
|
||||||
.WithBody(new
|
.WithBody(new
|
||||||
{
|
{
|
||||||
plaidInstitutionId = "institutionId",
|
providerInstitutionId = "institutionId",
|
||||||
plaidAccountId = "accountId",
|
providerAccountId = "accountId",
|
||||||
accountCurrencyCode = "USD",
|
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
@@ -99,7 +97,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
|
|
||||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
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())
|
.WithUserId(Guid.NewGuid())
|
||||||
.WithBody(new
|
.WithBody(new
|
||||||
{
|
{
|
||||||
plaidInstitutionId = "id",
|
providerInstitutionId = "id",
|
||||||
plaidAccountId = "id",
|
providerAccountId = "id",
|
||||||
plaidAccountName = "Some Account",
|
providerAccountName = "Some Account",
|
||||||
accountCurrencyCode = "USD",
|
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
@@ -145,10 +142,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
.WithUserId(user.Id)
|
.WithUserId(user.Id)
|
||||||
.WithBody(new
|
.WithBody(new
|
||||||
{
|
{
|
||||||
plaidInstitutionId = "id",
|
providerInstitutionId = "id",
|
||||||
plaidAccountId = "id",
|
providerAccountId = "id",
|
||||||
plaidAccountName = "Some Account",
|
providerAccountName = "Some Account",
|
||||||
accountCurrencyCode = "USD",
|
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
@@ -156,7 +152,7 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
|
|
||||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
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)
|
.WithUserId(user.Id)
|
||||||
.WithBody(new
|
.WithBody(new
|
||||||
{
|
{
|
||||||
plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
|
providerInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
|
||||||
plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
|
providerAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
|
||||||
plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
|
providerAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
|
||||||
accountCurrencyCode = "USD",
|
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
@@ -252,20 +247,15 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
|
|
||||||
var newAccountId = "newAccountId";
|
var newAccountId = "newAccountId";
|
||||||
var newAccountName = "New Account";
|
var newAccountName = "New Account";
|
||||||
var expectedBalance = 100;
|
|
||||||
var expectedCurrencyCode = "USD";
|
|
||||||
|
|
||||||
using var request = HttpRequestBuilder.New()
|
using var request = HttpRequestBuilder.New()
|
||||||
.Post(AddUri)
|
.Post(AddUri)
|
||||||
.WithUserId(user.Id)
|
.WithUserId(user.Id)
|
||||||
.WithBody(new
|
.WithBody(new
|
||||||
{
|
{
|
||||||
plaidInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
|
providerInstitutionId = ((PlaidInstitutionMetadata)institution.Metadata!).PlaidId,
|
||||||
plaidAccountId = newAccountId,
|
providerAccountId = newAccountId,
|
||||||
plaidAccountName = newAccountName,
|
providerAccountName = newAccountName,
|
||||||
accountCurrentBalance = expectedBalance,
|
|
||||||
accountAvailableBalance = expectedBalance,
|
|
||||||
accountCurrencyCode = expectedCurrencyCode,
|
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user