diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..94e0ef1 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "aspire", + "request": "launch", + "name": "Aspire: Launch default apphost", + "program": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Migrations/20260302015827_AddInstitutionsDbSet.Designer.cs b/src/FiscalOS.Infra/Migrations/20260302015827_AddInstitutionsDbSet.Designer.cs new file mode 100644 index 0000000..433ff1e --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260302015827_AddInstitutionsDbSet.Designer.cs @@ -0,0 +1,507 @@ +// +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("20260302015827_AddInstitutionsDbSet")] + partial class AddInstitutionsDbSet + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId"); + + b.HasIndex("UserId"); + + b.ToTable("Account"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .IsUnique(); + + b.ToTable("AccountMetadata"); + + b.HasDiscriminator("Type").HasValue("AccountMetadata"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("Available") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrencyCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Current") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("Balance"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Institutions"); + }); + + modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InstitutionId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstitutionId") + .IsUnique(); + + b.ToTable("InstitutionMetadata"); + + b.HasDiscriminator("Type").HasValue("InstitutionMetadata"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Revoked") + .HasColumnType("INTEGER"); + + b.Property("Token") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedDataKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EncryptionKeyId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccountId") + .HasColumnType("TEXT"); + + b.Property("AccountId1") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MerchantName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("AccountId1"); + + b.HasIndex("UserId"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("TransactionId") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId") + .IsUnique(); + + b.ToTable("TransactionMetadata"); + + b.HasDiscriminator("Type").HasValue("Plaid"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata"); + + b.Property("Cursor") + .HasColumnType("TEXT"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidInstitutionMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PlaidName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasDiscriminator().HasValue("Plaid"); + }); + + modelBuilder.Entity("FiscalOS.Infra.Transactions.Plaid.PlaidTransactionMetadata", b => + { + b.HasBaseType("FiscalOS.Core.Transactions.TransactionMetadata"); + + b.Property("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.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"); + }); + + modelBuilder.Entity("FiscalOS.Core.Transactions.Transaction", b => + { + b.Navigation("Metadata"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/20260302015827_AddInstitutionsDbSet.cs b/src/FiscalOS.Infra/Migrations/20260302015827_AddInstitutionsDbSet.cs new file mode 100644 index 0000000..419c13e --- /dev/null +++ b/src/FiscalOS.Infra/Migrations/20260302015827_AddInstitutionsDbSet.cs @@ -0,0 +1,168 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FiscalOS.Infra.Migrations +{ + /// + public partial class AddInstitutionsDbSet : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Transaction_Account_AccountId", + table: "Transaction"); + + migrationBuilder.DropForeignKey( + name: "FK_Transaction_Account_AccountId1", + table: "Transaction"); + + migrationBuilder.DropForeignKey( + name: "FK_Transaction_Users_UserId", + table: "Transaction"); + + migrationBuilder.DropForeignKey( + name: "FK_TransactionMetadata_Transaction_TransactionId", + table: "TransactionMetadata"); + + migrationBuilder.DropPrimaryKey( + name: "PK_Transaction", + table: "Transaction"); + + migrationBuilder.RenameTable( + name: "Transaction", + newName: "Transactions"); + + migrationBuilder.RenameIndex( + name: "IX_Transaction_UserId", + table: "Transactions", + newName: "IX_Transactions_UserId"); + + migrationBuilder.RenameIndex( + name: "IX_Transaction_AccountId1", + table: "Transactions", + newName: "IX_Transactions_AccountId1"); + + migrationBuilder.RenameIndex( + name: "IX_Transaction_AccountId", + table: "Transactions", + newName: "IX_Transactions_AccountId"); + + migrationBuilder.AddPrimaryKey( + name: "PK_Transactions", + table: "Transactions", + column: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_TransactionMetadata_Transactions_TransactionId", + table: "TransactionMetadata", + column: "TransactionId", + principalTable: "Transactions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Transactions_Account_AccountId", + table: "Transactions", + column: "AccountId", + principalTable: "Account", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Transactions_Account_AccountId1", + table: "Transactions", + column: "AccountId1", + principalTable: "Account", + principalColumn: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_Transactions_Users_UserId", + table: "Transactions", + column: "UserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_TransactionMetadata_Transactions_TransactionId", + table: "TransactionMetadata"); + + migrationBuilder.DropForeignKey( + name: "FK_Transactions_Account_AccountId", + table: "Transactions"); + + migrationBuilder.DropForeignKey( + name: "FK_Transactions_Account_AccountId1", + table: "Transactions"); + + migrationBuilder.DropForeignKey( + name: "FK_Transactions_Users_UserId", + table: "Transactions"); + + migrationBuilder.DropPrimaryKey( + name: "PK_Transactions", + table: "Transactions"); + + migrationBuilder.RenameTable( + name: "Transactions", + newName: "Transaction"); + + migrationBuilder.RenameIndex( + name: "IX_Transactions_UserId", + table: "Transaction", + newName: "IX_Transaction_UserId"); + + migrationBuilder.RenameIndex( + name: "IX_Transactions_AccountId1", + table: "Transaction", + newName: "IX_Transaction_AccountId1"); + + migrationBuilder.RenameIndex( + name: "IX_Transactions_AccountId", + table: "Transaction", + newName: "IX_Transaction_AccountId"); + + migrationBuilder.AddPrimaryKey( + name: "PK_Transaction", + table: "Transaction", + column: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_Transaction_Account_AccountId", + table: "Transaction", + column: "AccountId", + principalTable: "Account", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Transaction_Account_AccountId1", + table: "Transaction", + column: "AccountId1", + principalTable: "Account", + principalColumn: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_Transaction_Users_UserId", + table: "Transaction", + column: "UserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_TransactionMetadata_Transaction_TransactionId", + table: "TransactionMetadata", + column: "TransactionId", + principalTable: "Transaction", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/src/FiscalOS.Infra/Migrations/AppDbContextModelSnapshot.cs b/src/FiscalOS.Infra/Migrations/AppDbContextModelSnapshot.cs index ab97ae3..f111fb6 100644 --- a/src/FiscalOS.Infra/Migrations/AppDbContextModelSnapshot.cs +++ b/src/FiscalOS.Infra/Migrations/AppDbContextModelSnapshot.cs @@ -284,7 +284,7 @@ namespace FiscalOS.Infra.Migrations b.HasIndex("UserId"); - b.ToTable("Transaction"); + b.ToTable("Transactions"); }); modelBuilder.Entity("FiscalOS.Core.Transactions.TransactionMetadata", b => @@ -324,7 +324,6 @@ namespace FiscalOS.Infra.Migrations b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata"); b.Property("Cursor") - .IsRequired() .HasColumnType("TEXT"); b.Property("PlaidId") diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidAddedTransactionHandler.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidAddedTransactionHandler.cs index 4426093..ee63507 100644 --- a/src/FiscalOS.Infra/Transactions/Plaid/PlaidAddedTransactionHandler.cs +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidAddedTransactionHandler.cs @@ -43,9 +43,9 @@ internal sealed class PlaidAddedTransactionHandler : IPlaidAddedTransactionHandl account.UserId, account.Id, addedTransaction.MerchantName, - addedTransaction.OriginalDescription, + addedTransaction.Description, addedTransaction.Amount, - addedTransaction.Datetime, + addedTransaction.PostedDate, transactionMetadata ); account.AddTransaction(transaction); diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidExtensions.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidExtensions.cs new file mode 100644 index 0000000..07abb16 --- /dev/null +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidExtensions.cs @@ -0,0 +1,23 @@ +namespace FiscalOS.Infra.Transactions.Plaid; + +public static class PlaidExtensions +{ +#pragma warning disable CA1034 + extension(Transaction transaction) + { + public string Description => transaction.OriginalDescription ?? ""; + public DateTimeOffset PostedDate => transaction.Datetime ?? ( + transaction.Date.HasValue + ? new DateTimeOffset( + transaction.Date.Value.Year, + transaction.Date.Value.Month, + transaction.Date.Value.Day, + 0, + 0, + 0, + TimeSpan.Zero + ) + : throw new InvalidOperationException($"Transaction {transaction.TransactionId} does not have a valid date or datetime") + ); + } +} \ No newline at end of file diff --git a/src/FiscalOS.Infra/Transactions/Plaid/PlaidModifiedTransactionHandler.cs b/src/FiscalOS.Infra/Transactions/Plaid/PlaidModifiedTransactionHandler.cs index 9a9a139..9734f86 100644 --- a/src/FiscalOS.Infra/Transactions/Plaid/PlaidModifiedTransactionHandler.cs +++ b/src/FiscalOS.Infra/Transactions/Plaid/PlaidModifiedTransactionHandler.cs @@ -63,9 +63,9 @@ internal sealed class PlaidModifiedTransactionHandler : IPlaidModifiedTransactio existing.UserId, existing.AccountId, plaidModifiedTransaction.MerchantName, - plaidModifiedTransaction.OriginalDescription, + plaidModifiedTransaction.Description, plaidModifiedTransaction.Amount, - plaidModifiedTransaction.Datetime, + plaidModifiedTransaction.PostedDate, plaidMetadata );