feat(api,web): retrieve and display transactions

This commit is contained in:
Stevan Freeborn
2026-03-10 07:42:51 -05:00
parent e2d2058960
commit 3408f6ea4f
25 changed files with 595 additions and 100 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"cSpell.words": ["Encryptor"],
"cSpell.words": ["Dtos", "Encryptor", "Validatable"],
"python-envs.defaultEnvManager": "ms-python.python:system",
"search.exclude": {
"**/Migrations": true
@@ -1,3 +1,5 @@
using FiscalOS.API.Accounts.Add;
namespace FiscalOS.API.Accounts;
internal static class AccountsExtensions
+4
View File
@@ -1,3 +1,7 @@
using FiscalOS.API.Auth.Login;
using FiscalOS.API.Auth.Logout;
using FiscalOS.API.Auth.Refresh;
namespace FiscalOS.API.Auth;
internal static class AuthExtensions
+2
View File
@@ -1,3 +1,5 @@
namespace FiscalOS.API.Auth.Logout;
internal static class Endpoint
{
private const string Route = "/logout";
+36
View File
@@ -0,0 +1,36 @@
namespace FiscalOS.API.Common;
internal sealed record PagedQuery
{
public int PageNumber { get; init; }
public int PageSize { get; init; }
public static async ValueTask<PagedQuery> BindAsync(HttpContext context)
{
var pageNumber = int.TryParse(context.Request.Query["pageNumber"], out var pn) ? pn : 1;
var pageSize = int.TryParse(context.Request.Query["pageSize"], out var ps) ? ps : 1000;
return new PagedQuery
{
PageNumber = pageNumber,
PageSize = pageSize
};
}
public Dictionary<string, string[]> Validate()
{
var validationResults = new Dictionary<string, string[]>();
if (PageNumber <= 0)
{
validationResults[nameof(PageNumber)] = ["PageNumber must be greater than 0."];
}
if (PageSize <= 0 || PageSize > 1000)
{
validationResults[nameof(PageSize)] = ["PageSize must be between 1 and 1000."];
}
return validationResults;
}
}
+34
View File
@@ -0,0 +1,34 @@
namespace FiscalOS.API.Common;
internal sealed record PagedResponse<T>
{
public int PageNumber { get; init; }
public int PageSize { get; init; }
public int TotalItems { get; init; }
public int TotalPages { get; init; }
public T[] Items { get; init; } = [];
[JsonConstructor]
private PagedResponse()
{
}
public static PagedResponse<T> From(
int pageNumber,
int pageSize,
int totalItems,
T[] items
)
{
var totalPages = (int)Math.Ceiling((double)totalItems / pageSize);
return new PagedResponse<T>
{
PageNumber = pageNumber,
PageSize = pageSize,
TotalItems = totalItems,
TotalPages = totalPages,
Items = items
};
}
}
@@ -1,3 +1,8 @@
using FiscalOS.API.Institutions.Connect;
using FiscalOS.API.Institutions.Get;
using FiscalOS.API.Institutions.GetAvailable;
using FiscalOS.API.Institutions.Link;
namespace FiscalOS.API.Institutions;
internal static class InstitutionsExtensions
@@ -0,0 +1,53 @@
namespace FiscalOS.API.Transactions.Get;
internal static class Endpoint
{
private const string Route = "/";
public static RouteHandlerBuilder MapGetEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapGet(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
PagedQuery pagedQuery,
[FromServices] AppDbContext appDbContext,
CancellationToken ct
)
{
var pagedQueryValidationResults = pagedQuery.Validate();
if (pagedQueryValidationResults.Count is not 0)
{
return Results.ValidationProblem(pagedQueryValidationResults);
}
var userId = httpContext.GetUserId();
var query = appDbContext.Transactions
.Where(t => t.UserId == userId);
var transactions = await query
.Skip((pagedQuery.PageNumber - 1) * pagedQuery.PageSize)
.Take(pagedQuery.PageSize)
// TODO: Migrate Date to DateTime
// to avoid in-memory sorting
.AsAsyncEnumerable()
.OrderByDescending(t => t.Date)
.ToListAsync(ct);
var count = await query
.Where(t => t.UserId == userId)
.CountAsync(ct);
var transactionDtos = transactions.Select(TransactionDto.From).ToArray();
var pagedResponse = PagedResponse<TransactionDto>.From(
pagedQuery.PageNumber,
pagedQuery.PageSize,
count,
transactionDtos
);
return Results.Ok(pagedResponse);
}
}
@@ -0,0 +1,29 @@
using Transaction = FiscalOS.Core.Transactions.Transaction;
namespace FiscalOS.API.Transactions;
internal sealed record TransactionDto
{
public Guid Id { get; init; }
public string MerchantName { get; init; } = string.Empty;
public decimal Amount { get; init; }
public DateTimeOffset Date { get; init; }
public string Description { get; init; } = string.Empty;
[JsonConstructor]
private TransactionDto()
{
}
public static TransactionDto From(Transaction transaction)
{
return new TransactionDto
{
Id = transaction.Id,
MerchantName = transaction.MerchantName,
Amount = transaction.Amount,
Date = transaction.Date,
Description = transaction.Description
};
}
}
@@ -1,3 +1,7 @@
using FiscalOS.API.Transactions.FireWebhook;
using FiscalOS.API.Transactions.Get;
using FiscalOS.API.Transactions.Webhook;
namespace FiscalOS.API.Transactions;
internal static class TransactionsExtensions
@@ -9,13 +13,14 @@ internal static class TransactionsExtensions
var transactionsGroup = app.MapGroup(RouteGroupPrefix)
.RequireAuthorization();
transactionsGroup.MapGetEndpoint();
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
if (app.Environment.IsProduction() is false)
{
transactionsGroup.MapFireWebhookEndpoint();
}
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
return transactionsGroup;
}
}
+1 -9
View File
@@ -3,19 +3,11 @@ global using System.Security.Claims;
global using System.Text.Json.Serialization;
global using FiscalOS.API.Accounts;
global using FiscalOS.API.Accounts.Add;
global using FiscalOS.API.Auth;
global using FiscalOS.API.Auth.Login;
global using FiscalOS.API.Auth.Refresh;
global using FiscalOS.API.Common;
global using FiscalOS.API.Http;
global using FiscalOS.API.Institutions;
global using FiscalOS.API.Institutions.Connect;
global using FiscalOS.API.Institutions.Get;
global using FiscalOS.API.Institutions.GetAvailable;
global using FiscalOS.API.Institutions.Link;
global using FiscalOS.API.Transactions;
global using FiscalOS.API.Transactions.FireWebhook;
global using FiscalOS.API.Transactions.Webhook;
global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Queuing;
+48 -30
View File
@@ -1,23 +1,23 @@
<script setup lang="ts">
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
import { useAuthService } from '@/composables/useAuthService';
import { useUserStore } from '@/stores/userStore';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
import { useAuthService } from '@/composables/useAuthService';
import { useUserStore } from '@/stores/userStore';
import { computed } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
const userStore = useUserStore();
const router = useRouter();
const authService = useAuthService(userStore);
const userStore = useUserStore();
const router = useRouter();
const authService = useAuthService(userStore);
const asideClasses = computed(() => ({
const asideClasses = computed(() => ({
collapsed: userStore.user?.sidebarCollapsed,
}));
}));
function handleToggleButtonClick() {
function handleToggleButtonClick() {
userStore.toggleSidebar();
}
}
async function handleLogout() {
async function handleLogout() {
const logoutResult = await authService.logout();
if (logoutResult.err) {
@@ -27,22 +27,40 @@ async function handleLogout() {
userStore.logUserOut();
router.push({ path: '/public/login' });
}
}
</script>
<template>
<aside :class="asideClasses">
<button @click="handleToggleButtonClick" type="button" class="toggle-button">
<button
@click="handleToggleButtonClick"
type="button"
class="toggle-button"
>
<LeftArrowIcon />
</button>
<button class="logout-button" type="button" @click="handleLogout">
<nav>
<ul>
<li>
<RouterLink to="/">Accounts</RouterLink>
</li>
<li>
<RouterLink to="/transactions">Transactions</RouterLink>
</li>
</ul>
</nav>
<button
class="logout-button"
type="button"
@click="handleLogout"
>
Logout
</button>
</aside>
</template>
<style scoped>
aside {
aside {
--sidebar-width: 15.625rem;
--button-size: 1.5rem;
--transition-duration: 0.5s;
@@ -55,22 +73,22 @@ aside {
transition-property: width, transform;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
}
}
@media screen and (max-width: 48rem) {
@media screen and (max-width: 48rem) {
aside {
position: absolute;
right: 0;
}
}
}
.logout-button {
.logout-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
}
.toggle-button {
.toggle-button {
display: flex;
align-items: center;
justify-content: center;
@@ -84,19 +102,19 @@ aside {
transition-property: transform;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
}
}
aside.collapsed {
aside.collapsed {
width: calc(0.125rem + var(--button-size) / 2);
}
}
aside.collapsed .toggle-button {
aside.collapsed .toggle-button {
transform: rotate(180deg);
}
}
.toggle-button svg {
.toggle-button svg {
--size: 1rem;
width: var(--size);
height: var(--size);
}
}
</style>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { Transaction } from '@/services/transactionService';
defineProps<{
transaction: Transaction
}>();
</script>
<template>
<div>
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,27 @@
import { ClientConfig, ClientFactoryKey } from '@/services/client';
import { TransactionServiceFactoryKey } from '@/services/transactionService';
import type { UserStore } from '@/stores/userStore';
import { inject } from 'vue';
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;
}
+16 -8
View File
@@ -1,21 +1,29 @@
import './assets/css/main.css';
import { createApp } from "vue";
import { createPinia } from "pinia";
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from "./App.vue";
import router from "./router";
import { ClientFactory, ClientFactoryKey } from "./services/client";
import { AuthServiceFactory, AuthServiceFactoryKey } from "./services/authService";
import { InstituionServiceFactoryKey, InstitutionServiceFactory } from './services/institutionService';
import App from './App.vue';
import router from './router';
import { ClientFactory, ClientFactoryKey } from './services/client';
import { AuthServiceFactory, AuthServiceFactoryKey } from './services/authService';
import {
InstituionServiceFactoryKey,
InstitutionServiceFactory,
} from './services/institutionService';
import {
TransactionServiceFactory,
TransactionServiceFactoryKey,
} from './services/transactionService';
const app = createApp(App);
app.provide(ClientFactoryKey, new ClientFactory());
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
app.provide(TransactionServiceFactoryKey, new TransactionServiceFactory());
app.use(createPinia());
app.use(router);
app.mount("#app");
app.mount('#app');
+4
View File
@@ -62,6 +62,10 @@ const router = createRouter({
path: '/',
component: () => import('../views/HomeView.vue'),
},
{
path: '/transactions',
component: () => import('../views/TransactionsView.vue'),
}
],
},
],
@@ -0,0 +1,72 @@
import type { InjectionKey } from 'vue';
import { ClientRequest, type IClient } from './client';
import { Err, Ok, type Result } from 'ts-results';
type TransactionServiceFactoryKeyType = InjectionKey<ITransactionServiceFactory>;
export const TransactionServiceFactoryKey: TransactionServiceFactoryKeyType =
Symbol('AuthServiceFactory');
export interface ITransactionServiceFactory {
create: (client: IClient) => ITransactionService;
}
export class TransactionServiceFactory implements ITransactionServiceFactory {
create(client: IClient): ITransactionService {
return new TransactionService(client);
}
}
export interface ITransactionService {
get: (pageNumber?: number, pageSize?: number) => Promise<Result<Page<Transaction>, Error[]>>;
}
export class TransactionService implements ITransactionService {
private readonly client: IClient;
private readonly endpoints = {
get: '/api/transactions',
};
constructor(client: IClient) {
this.client = client;
}
async get(pageNumber: number = 1, pageSize: number = 500) {
const queryParams = new URLSearchParams({
pageNumber: pageNumber.toString(),
pageSize: pageSize.toString(),
});
const url = this.endpoints.get + '?' + queryParams.toString();
const request = new ClientRequest(url);
try {
const response = await this.client.get(request);
if (response.ok === false) {
return Err([new Error('Failed to retrieve transactions.')]);
}
const data = await response.json();
return Ok(data as Page<Transaction>);
} catch (error) {
console.error(error);
return Err([new Error('Failed to retrieve transactions.')]);
}
}
}
export type Page<T> = {
pageNumber: number;
pageSize: number;
totalItems: number;
totalPages: number;
items: T[];
};
export type Transaction = {
id: string;
merchantName: string;
description: string;
amount: number;
date: string;
};
+3 -3
View File
@@ -147,7 +147,7 @@ async function handleAccountAddClick() {
</button>
</div>
<div v-if="institutionsData.status === 'loaded'" class="institutions-container">
<div v-for="institution in institutionsData.data" v-bind:key="institution.id">
<div v-for="institution in institutionsData.data" :key="institution.id">
<div class="institution-card">
<div>
<div>{{ institution.name }}</div>
@@ -160,7 +160,7 @@ async function handleAccountAddClick() {
</div>
<div v-if="targetInstitutionOfAdd != null && targetInstitutionOfAdd.id === institution.id">
<select v-model="targetAccountOfAdd">
<option v-for="account in availableAccounts" v-bind:key="account.providerId" :value="account">
<option v-for="account in availableAccounts" :key="account.providerId" :value="account">
{{ account.providerName }}
</option>
</select>
@@ -168,7 +168,7 @@ async function handleAccountAddClick() {
Add
</button>
</div>
<div v-for="accounts in institution.accounts" v-bind:key="accounts.id">
<div v-for="accounts in institution.accounts" :key="accounts.id">
<div>{{ accounts.name }}</div>
</div>
</div>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { useTransactionService } from '@/composables/useTransactionService';
import type { Transaction } from '@/services/transactionService';
import { useUserStore } from '@/stores/userStore';
import { onMounted, ref } from 'vue';
const userStore = useUserStore();
const transactionService = useTransactionService(userStore);
const transactions = ref<Transaction[]>([]);
onMounted(async () => {
const result = await transactionService.get();
if (result.err) {
alert(result.val.map(e => e.message).join('\n'));
return;
}
transactions.value = result.val.items;
});
</script>
<template>
<div>
<h1>Transactions</h1>
<ul>
<li v-for="transaction in transactions" :key="transaction.id">
<div class="card">
<div class="top">
<div>
<div>{{ transaction.merchantName }}</div>
</div>
<div>
<div>{{ transaction.amount }}</div>
</div>
</div>
<div class="bottom">
<p>{{ transaction.description }}</p>
</div>
</div>
</li>
</ul>
</div>
</template>
<style scoped></style>
+1
View File
@@ -10,6 +10,7 @@
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.3.2" />
<PackageVersion Include="Microsoft.Testing.Extensions.VSTestBridge" Version="2.1.0" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
</ItemGroup>
</Project>
@@ -22,6 +22,7 @@
<ProjectReference Include="..\..\src\FiscalOS.API\FiscalOS.API.csproj" />
<ProjectReference Include="..\..\src\FiscalOS.Core\FiscalOS.Core.csproj" />
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
<ProjectReference Include="..\FiscalOS.Tests.Common\FiscalOS.Tests.Common.csproj" />
</ItemGroup>
</Project>
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.WebUtilities;
namespace FiscalOS.API.Tests.Infra;
internal sealed class HttpRequestBuilder
@@ -8,6 +10,7 @@ internal sealed class HttpRequestBuilder
private string? _bearerToken;
private readonly Dictionary<string, string> _cookies = [];
private readonly Dictionary<string, string> _headers = [];
private readonly Dictionary<string, string?> _queryParameters = [];
private HttpRequestBuilder()
{
@@ -68,6 +71,12 @@ internal sealed class HttpRequestBuilder
return this;
}
public HttpRequestBuilder WithQueryParameter(string name, string value)
{
_queryParameters[name] = value;
return this;
}
public HttpRequestBuilder Post(Uri uri)
{
_method = HttpMethod.Post;
@@ -103,7 +112,11 @@ internal sealed class HttpRequestBuilder
throw new InvalidOperationException("URI must be set before building the request.");
}
var request = new HttpRequestMessage(_method, _uri);
var uri = _queryParameters.Count > 0
? QueryHelpers.AddQueryString(_uri.ToString(), _queryParameters)
: _uri.ToString();
var request = new HttpRequestMessage(_method, uri);
if (_body is not null)
{
@@ -0,0 +1,101 @@
using FiscalOS.API.Transactions;
namespace FiscalOS.API.Tests.Integration.Transactions;
public class GetTests(TestApi testApi) : IntegrationTest(testApi)
{
private static readonly Uri GetUri = new("/transactions", UriKind.Relative);
[Fact]
public async Task Get_WhenNotLoggedIn_ItShouldReturn401WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Get_WhenCalledWithInvalidPageNumber_ItShouldReturn400WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.WithQueryParameter("pageNumber", "-1")
.WithUserId(Guid.NewGuid())
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PageNumber"] = ["PageNumber must be greater than 0."],
});
}
[Theory]
[InlineData(0)]
[InlineData(1001)]
public async Task Get_WhenCalledWithInvalidPageSize_ItShouldReturn400WithProblemDetails(int pageSize)
{
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.WithQueryParameter("pageSize", pageSize.ToString(CultureInfo.InvariantCulture))
.WithUserId(Guid.NewGuid())
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
{
["PageSize"] = ["PageSize must be between 1 and 1000."],
});
}
[Fact]
public async Task Get_WhenCalled_ItShouldReturn200WithPagedResponseOfTransactions()
{
var user = await Api.ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
var user = UserBuilder.Create()
.WithInstitution(static ib =>
{
ib.WithMetadata();
ib.WithAccount(static ab =>
{
ab.WithMetadata();
ab.WithTransaction(static tb =>
{
tb.WithMetadata();
});
});
})
.Build();
await context.AddAsync(user, ct);
await context.SaveChangesAsync(ct);
return user;
}, TestContext.Current.CancellationToken);
using var request = HttpRequestBuilder.New()
.Get(GetUri)
.WithUserId(user.Id)
.Build();
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
(await response.Should()
.BeJsonContentOfType<PagedResponse<TransactionDto>>(HttpStatusCode.OK))
.Which
.Items
.Should()
.BeEquivalentTo(
user.Transactions.Select(TransactionDto.From)
);
}
}
@@ -0,0 +1,21 @@
namespace FiscalOS.API.Tests.Unit;
public class PagedResponseTests
{
[Fact]
public void From_WhenCalled_ItShouldReturnPageWithCorrectValues()
{
var pageNumber = 1;
var pageSize = 10;
var totalItems = 25;
string[] items = ["test"];
var results = PagedResponse<string>.From(pageNumber, pageSize, totalItems, items);
results.PageNumber.Should().Be(pageNumber);
results.PageSize.Should().Be(pageSize);
results.TotalItems.Should().Be(totalItems);
results.TotalPages.Should().Be(3);
results.Items.Should().BeEquivalentTo(items);
}
}
+3
View File
@@ -1,3 +1,4 @@
global using System.Globalization;
global using System.IdentityModel.Tokens.Jwt;
global using System.Net;
global using System.Net.Http.Headers;
@@ -10,6 +11,7 @@ global using System.Text.Json;
global using AwesomeAssertions.Execution;
global using AwesomeAssertions.Primitives;
global using FiscalOS.API.Common;
global using FiscalOS.API.Tests.Assertions;
global using FiscalOS.API.Tests.Infra;
global using FiscalOS.Core.Authentication;
@@ -18,6 +20,7 @@ global using FiscalOS.Core.Security;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Data;
global using FiscalOS.Tests.Common.Data;
global using Going.Plaid;
global using Going.Plaid.Entity;