feat: continued wip on supporting transactions

This commit is contained in:
Stevan Freeborn
2026-03-03 07:36:09 -06:00
parent 562a708c74
commit 74793b5800
33 changed files with 1335 additions and 67 deletions
+4 -1
View File
@@ -12,5 +12,8 @@
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"cSpell.words": ["Encryptor"],
"python-envs.defaultEnvManager": "ms-python.python:system"
"python-envs.defaultEnvManager": "ms-python.python:system",
"search.exclude": {
"**/Migrations": true
}
}
-2
View File
@@ -1,5 +1,3 @@
using Going.Plaid.Converters;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
@@ -1,6 +1,3 @@
using Going.Plaid;
using Going.Plaid.Entity;
namespace FiscalOS.API.Transactions.FireWebhook;
internal static class Endpoint
+2 -1
View File
@@ -21,7 +21,6 @@ global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Queuing;
global using FiscalOS.Core.Security;
global using FiscalOS.Core.Transactions;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Data;
@@ -30,6 +29,8 @@ global using FiscalOS.Infra.Transactions.Plaid;
global using FiscalOS.ServiceDefaults;
global using Going.Plaid;
global using Going.Plaid.Converters;
global using Going.Plaid.Entity;
global using Going.Plaid.Webhook;
global using Microsoft.AspNetCore.Mvc;
+10 -6
View File
@@ -1,5 +1,3 @@
using FiscalOS.Core.Transactions;
namespace FiscalOS.Core.Accounts;
public sealed class Account : Entity
@@ -16,11 +14,17 @@ public sealed class Account : Entity
private readonly List<Transaction> _transactions = [];
public IEnumerable<Transaction> Transactions => _transactions;
public static Account From(Guid institutionId, string name, AccountMetadata accountMetadata)
private Account()
{
}
public static Account From(
string name,
AccountMetadata accountMetadata
)
{
return new()
{
InstitutionId = institutionId,
Name = name,
Metadata = accountMetadata,
};
@@ -28,14 +32,14 @@ public sealed class Account : Entity
public void AddBalance(Balance balance)
{
ArgumentNullException.ThrowIfNull(balance);
ArgumentNullException.ThrowIfNull(balance, nameof(balance));
_balances.Add(balance);
}
public void AddTransaction(Transaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction);
ArgumentNullException.ThrowIfNull(transaction, nameof(transaction));
_transactions.Add(transaction);
}
+2
View File
@@ -17,6 +17,8 @@ public sealed class Balance : Entity
string currencyCode
)
{
ArgumentNullException.ThrowIfNull(currencyCode, nameof(currencyCode));
return new()
{
Current = current,
@@ -25,4 +25,11 @@ public sealed class Institution : Entity
Metadata = metadata
};
}
public void AddAccount(Account account)
{
ArgumentNullException.ThrowIfNull(account, nameof(account));
_accounts.Add(account);
}
}
+10
View File
@@ -16,6 +16,9 @@ public sealed class User : Entity
private readonly List<Account> _accounts = [];
public IEnumerable<Account> Accounts => _accounts;
private readonly List<Transaction> _transactions = [];
public IEnumerable<Transaction> Transactions => _transactions;
private User()
{
}
@@ -60,4 +63,11 @@ public sealed class User : Entity
_accounts.Add(account);
}
public void AddTransaction(Transaction transaction)
{
ArgumentNullException.ThrowIfNull(transaction, nameof(transaction));
_transactions.Add(transaction);
}
}
+13 -2
View File
@@ -19,6 +19,17 @@ public sealed class Transaction : Entity
{
}
public static Transaction From(
string merchantName,
string description,
decimal? amount,
DateTimeOffset? date,
TransactionMetadata metadata
)
{
return From(Guid.Empty, Guid.Empty, merchantName, description, amount, date, metadata);
}
public static Transaction From(
Guid userId,
Guid accountId,
@@ -29,8 +40,8 @@ public sealed class Transaction : Entity
TransactionMetadata metadata
)
{
ArgumentNullException.ThrowIfNull(merchantName);
ArgumentNullException.ThrowIfNull(description);
ArgumentNullException.ThrowIfNull(merchantName, nameof(merchantName));
ArgumentNullException.ThrowIfNull(description, nameof(description));
if (amount.HasValue is false)
{
+1
View File
@@ -2,3 +2,4 @@ global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security;
global using FiscalOS.Core.Transactions;
@@ -1,6 +1,3 @@
using FiscalOS.Infra.Common;
namespace FiscalOS.Infra.Accounts.Plaid;
public sealed class PlaidAccountMetadata : AccountMetadata
@@ -1,5 +1,3 @@
using FiscalOS.Infra.Common;
namespace FiscalOS.Infra.Accounts.Plaid;
public sealed class PlaidInstitutionMetadata : InstitutionMetadata
+2 -5
View File
@@ -1,6 +1,3 @@
using FiscalOS.Core.Transactions;
using FiscalOS.Infra.Transactions.Plaid;
using Account = FiscalOS.Core.Accounts.Account;
using Institution = FiscalOS.Core.Accounts.Institution;
using Transaction = FiscalOS.Core.Transactions.Transaction;
@@ -167,12 +164,12 @@ public sealed class AppDbContext(
modelBuilder.Entity<Transaction>(static eb =>
{
eb.HasOne(static t => t.User)
.WithMany()
.WithMany(static u => u.Transactions)
.HasForeignKey(static t => t.UserId)
.OnDelete(DeleteBehavior.Cascade);
eb.HasOne(static t => t.Account)
.WithMany()
.WithMany(static a => a.Transactions)
.HasForeignKey(static t => t.AccountId)
.OnDelete(DeleteBehavior.Cascade);
@@ -1,10 +1,5 @@
namespace FiscalOS.Infra.DependencyInjection;
using FiscalOS.Core.Queuing;
using FiscalOS.Infra.Accounts.Plaid;
using FiscalOS.Infra.Queuing;
using FiscalOS.Infra.Transactions.Plaid;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
@@ -1,6 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,6 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,6 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -0,0 +1,518 @@
// <auto-generated />
using System;
using FiscalOS.Infra.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260303124406_AddTransactionsNavigationPropertyToUser")]
partial class AddTransactionsNavigationPropertyToUser
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId");
b.HasIndex("UserId");
b.ToTable("Account");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId")
.IsUnique();
b.ToTable("AccountMetadata");
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<decimal>("Available")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Current")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Balance");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Institutions");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId")
.IsUnique();
b.ToTable("InstitutionMetadata");
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EncryptedDataKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EncryptionKeyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<Guid?>("AccountId1")
.HasColumnType("TEXT");
b.Property<decimal>("Amount")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Date")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MerchantName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<Guid?>("UserId1")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("AccountId1");
b.HasIndex("UserId");
b.HasIndex("UserId1");
b.ToTable("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("TransactionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TransactionId")
.IsUnique();
b.ToTable("TransactionMetadata");
b.HasDiscriminator<string>("Type").HasValue("Plaid");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
b.Property<string>("Cursor")
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Transactions.Plaid.PlaidTransactionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Transactions.TransactionMetadata");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution")
.WithMany("Accounts")
.HasForeignKey("InstitutionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FiscalOS.Core.Identity.User", null)
.WithMany("Accounts")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Institution");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Account", null)
.WithOne("Metadata")
.HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Account", null)
.WithMany("Balances")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("Institutions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Institution", null)
.WithOne("Metadata")
.HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FiscalOS.Core.Accounts.Account", null)
.WithMany("Transactions")
.HasForeignKey("AccountId1");
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FiscalOS.Core.Identity.User", null)
.WithMany("Transactions")
.HasForeignKey("UserId1");
b.Navigation("Account");
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b =>
{
b.HasOne("FiscalOS.Core.Transactions.Transaction", null)
.WithOne("Metadata")
.HasForeignKey("FiscalOS.Core.Transactions.TransactionMetadata", "TransactionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
{
b.Navigation("Balances");
b.Navigation("Metadata");
b.Navigation("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Navigation("Accounts");
b.Navigation("Metadata");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("Accounts");
b.Navigation("Institutions");
b.Navigation("RefreshTokens");
b.Navigation("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.Navigation("Metadata");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class AddTransactionsNavigationPropertyToUser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "UserId1",
table: "Transactions",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Transactions_UserId1",
table: "Transactions",
column: "UserId1");
migrationBuilder.AddForeignKey(
name: "FK_Transactions_Users_UserId1",
table: "Transactions",
column: "UserId1",
principalTable: "Users",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Transactions_Users_UserId1",
table: "Transactions");
migrationBuilder.DropIndex(
name: "IX_Transactions_UserId1",
table: "Transactions");
migrationBuilder.DropColumn(
name: "UserId1",
table: "Transactions");
}
}
}
@@ -0,0 +1,500 @@
// <auto-generated />
using System;
using FiscalOS.Infra.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260303130637_UpdateAppDbContextToIncludeTransactionNavigationProperties")]
partial class UpdateAppDbContextToIncludeTransactionNavigationProperties
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId");
b.HasIndex("UserId");
b.ToTable("Account");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId")
.IsUnique();
b.ToTable("AccountMetadata");
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<decimal>("Available")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CurrencyCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Current")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("Balance");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Institutions");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId")
.IsUnique();
b.ToTable("InstitutionMetadata");
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EncryptedDataKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EncryptionKeyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<decimal>("Amount")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Date")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MerchantName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("UserId");
b.ToTable("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("TransactionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TransactionId")
.IsUnique();
b.ToTable("TransactionMetadata");
b.HasDiscriminator<string>("Type").HasValue("Plaid");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
b.Property<string>("Cursor")
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Infra.Transactions.Plaid.PlaidTransactionMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Transactions.TransactionMetadata");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution")
.WithMany("Accounts")
.HasForeignKey("InstitutionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FiscalOS.Core.Identity.User", null)
.WithMany("Accounts")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Institution");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Account", null)
.WithOne("Metadata")
.HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Account", null)
.WithMany("Balances")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("Institutions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Institution", null)
.WithOne("Metadata")
.HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Account", "Account")
.WithMany("Transactions")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("Transactions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b =>
{
b.HasOne("FiscalOS.Core.Transactions.Transaction", null)
.WithOne("Metadata")
.HasForeignKey("FiscalOS.Core.Transactions.TransactionMetadata", "TransactionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
{
b.Navigation("Balances");
b.Navigation("Metadata");
b.Navigation("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Navigation("Accounts");
b.Navigation("Metadata");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("Accounts");
b.Navigation("Institutions");
b.Navigation("RefreshTokens");
b.Navigation("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.Navigation("Metadata");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class UpdateAppDbContextToIncludeTransactionNavigationProperties : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Transactions_Account_AccountId1",
table: "Transactions");
migrationBuilder.DropForeignKey(
name: "FK_Transactions_Users_UserId1",
table: "Transactions");
migrationBuilder.DropIndex(
name: "IX_Transactions_AccountId1",
table: "Transactions");
migrationBuilder.DropIndex(
name: "IX_Transactions_UserId1",
table: "Transactions");
migrationBuilder.DropColumn(
name: "AccountId1",
table: "Transactions");
migrationBuilder.DropColumn(
name: "UserId1",
table: "Transactions");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "AccountId1",
table: "Transactions",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "UserId1",
table: "Transactions",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Transactions_AccountId1",
table: "Transactions",
column: "AccountId1");
migrationBuilder.CreateIndex(
name: "IX_Transactions_UserId1",
table: "Transactions",
column: "UserId1");
migrationBuilder.AddForeignKey(
name: "FK_Transactions_Account_AccountId1",
table: "Transactions",
column: "AccountId1",
principalTable: "Account",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Transactions_Users_UserId1",
table: "Transactions",
column: "UserId1",
principalTable: "Users",
principalColumn: "Id");
}
}
}
@@ -250,9 +250,6 @@ namespace FiscalOS.Infra.Migrations
b.Property<Guid>("AccountId")
.HasColumnType("TEXT");
b.Property<Guid?>("AccountId1")
.HasColumnType("TEXT");
b.Property<decimal>("Amount")
.HasColumnType("TEXT");
@@ -280,8 +277,6 @@ namespace FiscalOS.Infra.Migrations
b.HasIndex("AccountId");
b.HasIndex("AccountId1");
b.HasIndex("UserId");
b.ToTable("Transactions");
@@ -440,17 +435,13 @@ namespace FiscalOS.Infra.Migrations
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Account", "Account")
.WithMany()
.WithMany("Transactions")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FiscalOS.Core.Accounts.Account", null)
.WithMany("Transactions")
.HasForeignKey("AccountId1");
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany()
.WithMany("Transactions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
@@ -492,6 +483,8 @@ namespace FiscalOS.Infra.Migrations
b.Navigation("Institutions");
b.Navigation("RefreshTokens");
b.Navigation("Transactions");
});
modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b =>
@@ -3,7 +3,7 @@ namespace FiscalOS.Infra.Transactions.Plaid;
public static class PlaidExtensions
{
#pragma warning disable CA1034
extension(Transaction transaction)
extension(Going.Plaid.Entity.Transaction transaction)
{
public string Merchant => transaction.MerchantName ?? "Unknown merchant";
public string Description => transaction.OriginalDescription ?? "";
@@ -19,6 +19,14 @@ internal sealed class PlaidRemovedTransactionHandler : IPlaidRemovedTransactionH
_logger = logger;
}
public static PlaidRemovedTransactionHandler From(
AppDbContext appDbContext,
ILogger<PlaidRemovedTransactionHandler> logger
)
{
return new(appDbContext, logger);
}
public static PlaidRemovedTransactionHandler From(IServiceProvider serviceProvider)
{
return new(
@@ -1,6 +1,3 @@
using FiscalOS.Core.Transactions;
using FiscalOS.Infra.Common;
namespace FiscalOS.Infra.Transactions.Plaid;
public sealed class PlaidTransactionMetadata : TransactionMetadata
@@ -14,7 +11,7 @@ public sealed class PlaidTransactionMetadata : TransactionMetadata
public static PlaidTransactionMetadata From(string? plaidId)
{
ArgumentNullException.ThrowIfNull(plaidId);
ArgumentNullException.ThrowIfNull(plaidId, nameof(plaidId));
return new()
{
+3
View File
@@ -13,12 +13,15 @@ global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Queuing;
global using FiscalOS.Core.Security;
global using FiscalOS.Core.Transactions;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Authorization;
global using FiscalOS.Infra.Common;
global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.Queuing;
global using FiscalOS.Infra.Security;
global using FiscalOS.Infra.Transactions.Plaid;
global using Going.Plaid;
global using Going.Plaid.Entity;
@@ -59,7 +59,7 @@ public static class ServiceDefaultsExtensions
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
ArgumentNullException.ThrowIfNull(app, nameof(app));
if (app.Environment.IsDevelopment())
{
@@ -1,6 +1,3 @@
using Aspire.Hosting;
using Aspire.Hosting.Testing;
namespace FiscalOS.AppHost.Tests.Infra;
public sealed class AspireFixture : IAsyncLifetime
+3 -1
View File
@@ -1,4 +1,6 @@
global using Aspire.Hosting;
global using Aspire.Hosting.Testing;
global using FiscalOS.AppHost.Tests.Infra;
global using Microsoft.Playwright;
global using Microsoft.Playwright.Xunit.v3;
@@ -0,0 +1,44 @@
namespace FiscalOS.Infra.Tests.Integration;
public abstract class IntegrationTest : IAsyncLifetime
{
private bool _isDisposed;
private readonly FileSystem _fileSystem = new();
protected AppDbContext AppDbContext { get; }
protected IntegrationTest()
{
var options = Options.Create(new AppDbContextOptions()
{
DatabaseFilePath = $"{Guid.NewGuid()}.db",
});
AppDbContext = new(options, _fileSystem);
}
public async ValueTask InitializeAsync()
{
await AppDbContext.Database.MigrateAsync();
}
public async ValueTask DisposeAsync()
{
await DisposeAsync(true);
GC.SuppressFinalize(this);
}
protected virtual async Task DisposeAsync(bool disposing)
{
if (_isDisposed)
{
return;
}
if (disposing)
{
await AppDbContext.DisposeAsync();
}
_isDisposed = true;
}
}
@@ -0,0 +1,59 @@
namespace FiscalOS.Infra.Tests.Integration;
public class PlaidRemovedTransactionHandlerTests : IntegrationTest
{
private readonly Mock<ILogger<PlaidRemovedTransactionHandler>> _mockLogger = new();
private readonly PlaidRemovedTransactionHandler _sut;
public PlaidRemovedTransactionHandlerTests()
{
_sut = PlaidRemovedTransactionHandler.From(AppDbContext, _mockLogger.Object);
}
[Fact]
public async Task HandleAsync_WhenCalled_ItShouldRemoveTheCorrectTransactions()
{
var user = User.From(
"username",
"hashedPassword",
EncryptedDataKey.From("keyUsed", "encryptedKey")
);
var institutionMetadata = PlaidInstitutionMetadata.From(
"plaidId",
"plaidName",
"encryptedAccessToken",
"itemId"
);
var institution = Institution.From("institutionName", institutionMetadata);
user.AddInstitution(institution);
var accountMetadata = PlaidAccountMetadata.From("plaidId", "plaidName");
var account = Account.From("some account", accountMetadata);
user.AddAccount(account);
institution.AddAccount(account);
var transactionMetadata = PlaidTransactionMetadata.From("plaidId");
var transaction = Transaction.From(
"merchantName",
"description",
100,
DateTimeOffset.UtcNow,
transactionMetadata
);
user.AddTransaction(transaction);
account.AddTransaction(transaction);
await AppDbContext.AddAsync(user, TestContext.Current.CancellationToken);
await AppDbContext.SaveChangesAsync(TestContext.Current.CancellationToken);
var transactionToRemove = new Going.Plaid.Entity.RemovedTransaction()
{
TransactionId = transactionMetadata.PlaidId,
};
var result = await _sut.HandleAsync([transactionToRemove], TestContext.Current.CancellationToken);
result.Should().Be(1);
}
}
+7
View File
@@ -7,19 +7,26 @@ global using System.Text.Json;
global using AwesomeAssertions.Primitives;
global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security;
global using FiscalOS.Core.Transactions;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Authorization;
global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.Security;
global using FiscalOS.Infra.Tests.Assertions;
global using FiscalOS.Infra.Tests.Mocks;
global using FiscalOS.Infra.Transactions.Plaid;
global using Microsoft.AspNetCore.Authentication.JwtBearer;
global using Microsoft.AspNetCore.Authorization;
global using Microsoft.AspNetCore.Authorization.Policy;
global using Microsoft.AspNetCore.Http;
global using Microsoft.AspNetCore.Mvc;
global using Microsoft.EntityFrameworkCore;
global using Microsoft.Extensions.Configuration;
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.Options;
global using Microsoft.IdentityModel.Tokens;