Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
52 changes: 52 additions & 0 deletions .github/workflows/microservices-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Microservices CI

on:
push:
branches:
- microservices
pull_request:
branches:
- microservices

jobs:
build-services:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"

- name: Restore services
run: |
dotnet restore services/TrackMint.AuthService/TrackMint.AuthService.csproj
dotnet restore services/TrackMint.FinanceService/TrackMint.FinanceService.csproj
dotnet restore services/TrackMint.InsightsService/TrackMint.InsightsService.csproj
dotnet restore services/TrackMint.NotificationService/TrackMint.NotificationService.csproj
dotnet restore services/TrackMint.Gateway/TrackMint.Gateway.csproj
dotnet restore backend/PersonalFinanceTracker.Api/PersonalFinanceTracker.Api.csproj
dotnet restore tests/TrackMint.AuthService.Tests/TrackMint.AuthService.Tests.csproj
dotnet restore tests/TrackMint.Contracts.Tests/TrackMint.Contracts.Tests.csproj
dotnet restore tests/TrackMint.NotificationService.Tests/TrackMint.NotificationService.Tests.csproj

- name: Build services
run: |
dotnet build services/TrackMint.AuthService/TrackMint.AuthService.csproj --configuration Release --no-restore
dotnet build services/TrackMint.FinanceService/TrackMint.FinanceService.csproj --configuration Release --no-restore
dotnet build services/TrackMint.InsightsService/TrackMint.InsightsService.csproj --configuration Release --no-restore
dotnet build services/TrackMint.NotificationService/TrackMint.NotificationService.csproj --configuration Release --no-restore
dotnet build services/TrackMint.Gateway/TrackMint.Gateway.csproj --configuration Release --no-restore
dotnet build backend/PersonalFinanceTracker.Api/PersonalFinanceTracker.Api.csproj --configuration Release --no-restore

- name: Run service tests
run: |
dotnet test tests/TrackMint.AuthService.Tests/TrackMint.AuthService.Tests.csproj --configuration Release --no-restore
dotnet test tests/TrackMint.Contracts.Tests/TrackMint.Contracts.Tests.csproj --configuration Release --no-restore
dotnet test tests/TrackMint.NotificationService.Tests/TrackMint.NotificationService.Tests.csproj --configuration Release --no-restore

- name: Validate compose file
run: docker compose -f compose.microservices.yaml config
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ frontend/.env.local
# .NET
backend/**/bin/
backend/**/obj/
services/**/bin/
services/**/obj/
backend/.vs/
backend/*.log

Expand All @@ -38,3 +40,6 @@ backend/PersonalFinanceTracker.Api/appsettings.Development.json
# Docs
docs/**
!docs/SETUP.md

# Local Codex memory
codex-memory-bank/
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
<Compile Include="..\PersonalFinanceTracker.Infrastructure\**\*.cs" Exclude="..\PersonalFinanceTracker.Infrastructure\bin\**\*;..\PersonalFinanceTracker.Infrastructure\obj\**\*" Link="Infrastructure\%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\services\TrackMint.Contracts\TrackMint.Contracts.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using TrackMint.Contracts.Events;

namespace PersonalFinanceTracker.Application.Abstractions;

public interface IIntegrationEventPublisher
{
Task PublishAsync(IntegrationEvent integrationEvent, string routingKey, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<ItemGroup>
<ProjectReference Include="..\PersonalFinanceTracker.Domain\PersonalFinanceTracker.Domain.csproj" />
<ProjectReference Include="..\..\services\TrackMint.Contracts\TrackMint.Contracts.csproj" />
</ItemGroup>

<PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.Extensions.Options;
using PersonalFinanceTracker.Application.Abstractions;
using PersonalFinanceTracker.Infrastructure.Background;
using PersonalFinanceTracker.Infrastructure.Messaging;
using PersonalFinanceTracker.Infrastructure.Persistence;
using PersonalFinanceTracker.Infrastructure.Security;
using PersonalFinanceTracker.Infrastructure.Seed;
Expand Down Expand Up @@ -43,8 +44,12 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
services.AddScoped<IBalanceService, BalanceService>();
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<IDefaultCategorySeeder, DefaultCategorySeeder>();
services.AddSingleton<IIntegrationEventPublisher, NoOpIntegrationEventPublisher>();

services.AddHostedService<RecurringTransactionWorker>();
if (configuration.GetValue("Features:EnableRecurringWorker", true))
{
services.AddHostedService<RecurringTransactionWorker>();
}

return services;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Microsoft.Extensions.Logging;
using PersonalFinanceTracker.Application.Abstractions;
using TrackMint.Contracts.Events;

namespace PersonalFinanceTracker.Infrastructure.Messaging;

public sealed class NoOpIntegrationEventPublisher(ILogger<NoOpIntegrationEventPublisher> logger) : IIntegrationEventPublisher
{
public Task PublishAsync(IntegrationEvent integrationEvent, string routingKey, CancellationToken cancellationToken)
{
logger.LogDebug(
"Integration event skipped by no-op publisher. EventType={EventType} RoutingKey={RoutingKey} EventId={EventId}",
integrationEvent.EventType,
routingKey,
integrationEvent.EventId);

return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
using PersonalFinanceTracker.Domain.Entities;
using PersonalFinanceTracker.Domain.Enums;
using PersonalFinanceTracker.Infrastructure.Persistence;
using TrackMint.Contracts.Events;

namespace PersonalFinanceTracker.Infrastructure.Services;

public sealed class GoalService(
ApplicationDbContext dbContext,
ICurrentUserService currentUserService,
IBalanceService balanceService,
IAuditService auditService) : IGoalService
IAuditService auditService,
IIntegrationEventPublisher eventPublisher) : IGoalService
{
public async Task<IReadOnlyCollection<GoalResponse>> GetAllAsync(CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -149,6 +151,7 @@ private async Task<GoalResponse> AdjustGoalAsync(Guid id, GoalContributionReques
await dbContext.Transactions.AddAsync(transaction, cancellationToken);
}

var wasCompleted = goal.Status == GoalStatus.Completed;
goal.CurrentAmount = increase ? goal.CurrentAmount + request.Amount : goal.CurrentAmount - request.Amount;
goal.Status = goal.CurrentAmount >= goal.TargetAmount ? GoalStatus.Completed : GoalStatus.Active;

Expand All @@ -157,6 +160,17 @@ private async Task<GoalResponse> AdjustGoalAsync(Guid id, GoalContributionReques
await dbTransaction.CommitAsync(cancellationToken);

await auditService.WriteAsync(userId, increase ? "goal_contribution" : "goal_withdrawal", nameof(Goal), goal.Id, new { request.Amount }, cancellationToken);
if (!wasCompleted && goal.Status == GoalStatus.Completed)
{
await eventPublisher.PublishAsync(new GoalCompletedEvent
{
UserId = userId,
GoalId = goal.Id,
GoalName = goal.Name,
TargetAmount = goal.TargetAmount
}, "finance.goal.completed", cancellationToken);
}

return goal.ToResponse();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using PersonalFinanceTracker.Domain.Entities;
using PersonalFinanceTracker.Domain.Enums;
using PersonalFinanceTracker.Infrastructure.Persistence;
using TrackMint.Contracts.Events;

namespace PersonalFinanceTracker.Infrastructure.Services;

Expand All @@ -14,7 +15,8 @@ public sealed class RecurringTransactionService(
IAccountAccessService accountAccessService,
IRuleService ruleService,
IBalanceService balanceService,
IAuditService auditService) : IRecurringTransactionService
IAuditService auditService,
IIntegrationEventPublisher eventPublisher) : IRecurringTransactionService
{
public async Task<IReadOnlyCollection<RecurringTransactionResponse>> GetAllAsync(CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -63,6 +65,7 @@ public async Task<RecurringTransactionResponse> CreateAsync(CreateRecurringTrans
}
await dbTransaction.CommitAsync(cancellationToken);
await auditService.WriteAsync(userId, "recurring_created", nameof(RecurringTransaction), item.Id, new { item.Title, item.Amount }, cancellationToken);
await PublishGeneratedRecurringEventsAsync(item, generatedTransactions, cancellationToken);

var accountIds = await accountAccessService.GetAccessibleAccountIdsAsync(userId, cancellationToken);
return (await Query(accountIds).SingleAsync(x => x.Id == item.Id, cancellationToken)).ToResponse();
Expand Down Expand Up @@ -107,6 +110,7 @@ public async Task<RecurringTransactionResponse> UpdateAsync(Guid id, UpdateRecur
}
await dbTransaction.CommitAsync(cancellationToken);
await auditService.WriteAsync(userId, "recurring_updated", nameof(RecurringTransaction), item.Id, new { item.Title, item.Amount }, cancellationToken);
await PublishGeneratedRecurringEventsAsync(item, generatedTransactions, cancellationToken);

var accountIds = await accountAccessService.GetAccessibleAccountIdsAsync(userId, cancellationToken);
return (await Query(accountIds).SingleAsync(x => x.Id == item.Id, cancellationToken)).ToResponse();
Expand Down Expand Up @@ -170,6 +174,16 @@ public async Task ProcessDueItemsAsync(CancellationToken cancellationToken)
}

await dbTransaction.CommitAsync(cancellationToken);

foreach (var item in dueItems)
{
var generatedTransactions = await dbContext.Transactions
.AsNoTracking()
.Where(x => x.RecurringTransactionId == item.Id && x.CreatedAt >= DateTime.UtcNow.AddMinutes(-10))
.ToListAsync(cancellationToken);

await PublishGeneratedRecurringEventsAsync(item, generatedTransactions, cancellationToken);
}
}

private IQueryable<RecurringTransaction> Query(IReadOnlySet<Guid> accountIds) =>
Expand Down Expand Up @@ -258,4 +272,21 @@ private async Task RecalculateForImpactedOwnersAsync(Guid accountId, Guid? desti
await balanceService.RecalculateForUserAsync(ownerId, cancellationToken);
}
}

private async Task PublishGeneratedRecurringEventsAsync(
RecurringTransaction item,
IEnumerable<Transaction> generatedTransactions,
CancellationToken cancellationToken)
{
foreach (var transaction in generatedTransactions)
{
await eventPublisher.PublishAsync(new RecurringTransactionGeneratedEvent
{
UserId = item.UserId,
RecurringTransactionId = item.Id,
GeneratedTransactionId = transaction.Id,
RunDate = transaction.TransactionDate
}, "finance.recurring.generated", cancellationToken);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using PersonalFinanceTracker.Domain.Entities;
using PersonalFinanceTracker.Domain.Enums;
using PersonalFinanceTracker.Infrastructure.Persistence;
using TrackMint.Contracts.Events;

namespace PersonalFinanceTracker.Infrastructure.Services;

Expand All @@ -15,7 +16,8 @@ public sealed class TransactionService(
IAccountAccessService accountAccessService,
IBalanceService balanceService,
IRuleService ruleService,
IAuditService auditService) : ITransactionService
IAuditService auditService,
IIntegrationEventPublisher eventPublisher) : ITransactionService
{
public async Task<PagedResult<TransactionResponse>> GetAllAsync(TransactionQueryRequest request, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -92,6 +94,17 @@ public async Task<TransactionResponse> CreateAsync(CreateTransactionRequest requ
await dbTransaction.CommitAsync(cancellationToken);

await auditService.WriteAsync(userId, "transaction_created", nameof(Transaction), transaction.Id, new { transaction.Type, transaction.Amount }, cancellationToken);
await eventPublisher.PublishAsync(new TransactionCreatedEvent
{
UserId = userId,
TransactionId = transaction.Id,
AccountId = transaction.AccountId,
CategoryId = transaction.CategoryId,
Type = transaction.Type.ToString(),
Amount = transaction.Amount,
TransactionDate = transaction.TransactionDate
}, "finance.transaction.created", cancellationToken);
await PublishBudgetThresholdIfNeededAsync(transaction, cancellationToken);

return await GetByIdAsync(transaction.Id, cancellationToken);
}
Expand Down Expand Up @@ -130,6 +143,15 @@ public async Task<TransactionResponse> UpdateAsync(Guid id, UpdateTransactionReq
await dbTransaction.CommitAsync(cancellationToken);

await auditService.WriteAsync(userId, "transaction_updated", nameof(Transaction), transaction.Id, new { transaction.Type, transaction.Amount }, cancellationToken);
await eventPublisher.PublishAsync(new TransactionUpdatedEvent
{
UserId = userId,
TransactionId = transaction.Id,
AccountId = transaction.AccountId,
Type = transaction.Type.ToString(),
Amount = transaction.Amount
}, "finance.transaction.updated", cancellationToken);
await PublishBudgetThresholdIfNeededAsync(transaction, cancellationToken);

return await GetByIdAsync(transaction.Id, cancellationToken);
}
Expand All @@ -148,6 +170,12 @@ public async Task DeleteAsync(Guid id, CancellationToken cancellationToken)
await dbTransaction.CommitAsync(cancellationToken);

await auditService.WriteAsync(userId, "transaction_deleted", nameof(Transaction), transaction.Id, new { transaction.Type, transaction.Amount }, cancellationToken);
await eventPublisher.PublishAsync(new TransactionDeletedEvent
{
UserId = userId,
TransactionId = transaction.Id,
AccountId = transaction.AccountId
}, "finance.transaction.deleted", cancellationToken);
}

internal static IQueryable<Transaction> ApplyFilters(IQueryable<Transaction> query, TransactionQueryRequest request)
Expand Down Expand Up @@ -258,4 +286,54 @@ private async Task RecalculateForImpactedOwnersAsync(Guid accountId, Guid? desti
await balanceService.RecalculateForUserAsync(ownerId, cancellationToken);
}
}

private async Task PublishBudgetThresholdIfNeededAsync(Transaction transaction, CancellationToken cancellationToken)
{
if (transaction.Type != TransactionType.Expense || transaction.CategoryId is null)
{
return;
}

var budget = await dbContext.Budgets
.AsNoTracking()
.SingleOrDefaultAsync(x =>
x.UserId == transaction.UserId &&
x.CategoryId == transaction.CategoryId.Value &&
x.Month == transaction.TransactionDate.Month &&
x.Year == transaction.TransactionDate.Year,
cancellationToken);

if (budget is null)
{
return;
}

var monthStart = new DateOnly(budget.Year, budget.Month, 1);
var monthEnd = monthStart.AddMonths(1).AddDays(-1);
var actualSpend = await dbContext.Transactions
.AsNoTracking()
.Where(x =>
x.UserId == transaction.UserId &&
x.Type == TransactionType.Expense &&
x.CategoryId == budget.CategoryId &&
x.TransactionDate >= monthStart &&
x.TransactionDate <= monthEnd)
.SumAsync(x => x.Amount, cancellationToken);

var utilizationPercent = budget.Amount == 0 ? 0 : Math.Round((actualSpend / budget.Amount) * 100, 2);
if (utilizationPercent < budget.AlertThresholdPercent)
{
return;
}

await eventPublisher.PublishAsync(new BudgetThresholdCrossedEvent
{
UserId = transaction.UserId,
BudgetId = budget.Id,
CategoryId = budget.CategoryId,
BudgetAmount = budget.Amount,
ActualSpend = actualSpend,
UtilizationPercent = utilizationPercent
}, "finance.budget.threshold_crossed", cancellationToken);
}
}
Loading
Loading