diff --git a/MoneyMap/Migrations/20251011014001_AddTransferSupport.Designer.cs b/MoneyMap/Migrations/20251011014001_AddTransferSupport.Designer.cs new file mode 100644 index 0000000..39303d8 --- /dev/null +++ b/MoneyMap/Migrations/20251011014001_AddTransferSupport.Designer.cs @@ -0,0 +1,539 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MoneyMap.Data; + +#nullable disable + +namespace MoneyMap.Migrations +{ + [DbContext(typeof(MoneyMapContext))] + [Migration("20251011014001_AddTransferSupport")] + partial class AddTransferSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MoneyMap.Models.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .HasColumnType("int"); + + b.Property("Institution") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Institution", "Last4", "Owner"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Last4") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Nickname") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Issuer", "Last4", "Owner"); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)") + .HasDefaultValue("application/octet-stream"); + + b.Property("Currency") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("FileHashSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("Merchant") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime2"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Subtotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Tax") + .HasColumnType("decimal(18,2)"); + + b.Property("Total") + .HasColumnType("decimal(18,2)"); + + b.Property("TransactionId") + .HasColumnType("bigint"); + + b.Property("UploadedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("TransactionId", "FileHashSha256") + .IsUnique(); + + b.ToTable("Receipts"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("LineNumber") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("Quantity") + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("Sku") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UnitPrice") + .HasColumnType("decimal(18,4)"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "LineNumber"); + + b.ToTable("ReceiptLineItems"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Confidence") + .HasColumnType("decimal(5,4)"); + + b.Property("Error") + .HasColumnType("nvarchar(max)"); + + b.Property("ExtractedTextPath") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderJobId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RawProviderPayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReceiptId") + .HasColumnType("bigint"); + + b.Property("StartedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Success") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ReceiptId", "StartedAtUtc"); + + b.ToTable("ReceiptParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CardId") + .HasColumnType("int"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Last4") + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("Memo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferToAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("Amount"); + + b.HasIndex("CardId"); + + b.HasIndex("Category"); + + b.HasIndex("Date"); + + b.HasIndex("TransferToAccountId"); + + b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId") + .IsUnique() + .HasFilter("[CardId] IS NOT NULL"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transfer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DestinationAccountId") + .HasColumnType("int"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalTransactionId") + .HasColumnType("bigint"); + + b.Property("SourceAccountId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("DestinationAccountId"); + + b.HasIndex("OriginalTransactionId"); + + b.HasIndex("SourceAccountId"); + + b.ToTable("Transfers"); + }); + + modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Pattern") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("CategoryMappings"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.HasOne("MoneyMap.Models.Account", "Account") + .WithMany("Cards") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.HasOne("MoneyMap.Models.Transaction", "Transaction") + .WithMany("Receipts") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b => + { + b.HasOne("MoneyMap.Models.Receipt", "Receipt") + .WithMany("LineItems") + .HasForeignKey("ReceiptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Receipt"); + }); + + modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b => + { + b.HasOne("MoneyMap.Models.Receipt", "Receipt") + .WithMany("ParseLogs") + .HasForeignKey("ReceiptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Receipt"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.HasOne("MoneyMap.Models.Account", "Account") + .WithMany("Transactions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MoneyMap.Models.Card", "Card") + .WithMany("Transactions") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MoneyMap.Models.Account", "TransferToAccount") + .WithMany() + .HasForeignKey("TransferToAccountId"); + + b.Navigation("Account"); + + b.Navigation("Card"); + + b.Navigation("TransferToAccount"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transfer", b => + { + b.HasOne("MoneyMap.Models.Account", "DestinationAccount") + .WithMany("DestinationTransfers") + .HasForeignKey("DestinationAccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MoneyMap.Models.Transaction", "OriginalTransaction") + .WithMany() + .HasForeignKey("OriginalTransactionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MoneyMap.Models.Account", "SourceAccount") + .WithMany("SourceTransfers") + .HasForeignKey("SourceAccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("DestinationAccount"); + + b.Navigation("OriginalTransaction"); + + b.Navigation("SourceAccount"); + }); + + modelBuilder.Entity("MoneyMap.Models.Account", b => + { + b.Navigation("Cards"); + + b.Navigation("DestinationTransfers"); + + b.Navigation("SourceTransfers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("MoneyMap.Models.Card", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("MoneyMap.Models.Receipt", b => + { + b.Navigation("LineItems"); + + b.Navigation("ParseLogs"); + }); + + modelBuilder.Entity("MoneyMap.Models.Transaction", b => + { + b.Navigation("Receipts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MoneyMap/Migrations/20251011014001_AddTransferSupport.cs b/MoneyMap/Migrations/20251011014001_AddTransferSupport.cs new file mode 100644 index 0000000..3c430cc --- /dev/null +++ b/MoneyMap/Migrations/20251011014001_AddTransferSupport.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoneyMap.Migrations +{ + /// + public partial class AddTransferSupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TransferToAccountId", + table: "Transactions", + type: "int", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_TransferToAccountId", + table: "Transactions", + column: "TransferToAccountId"); + + migrationBuilder.AddForeignKey( + name: "FK_Transactions_Accounts_TransferToAccountId", + table: "Transactions", + column: "TransferToAccountId", + principalTable: "Accounts", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Transactions_Accounts_TransferToAccountId", + table: "Transactions"); + + migrationBuilder.DropIndex( + name: "IX_Transactions_TransferToAccountId", + table: "Transactions"); + + migrationBuilder.DropColumn( + name: "TransferToAccountId", + table: "Transactions"); + } + } +} diff --git a/MoneyMap/Pages/CreateTransfer.cshtml b/MoneyMap/Pages/CreateTransfer.cshtml new file mode 100644 index 0000000..4c13592 --- /dev/null +++ b/MoneyMap/Pages/CreateTransfer.cshtml @@ -0,0 +1,128 @@ +@page +@model MoneyMap.Pages.CreateTransferModel +@{ + ViewData["Title"] = "Create Transfer"; +} + + + Create Transfer + Back to Transactions + + +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + + @Model.SuccessMessage + + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + + @Model.ErrorMessage + + +} + + + + + + Transfer Details + + + + Note: This will create two matching transactions - a debit from the source account and a credit to the destination account. + + + + + + From Account + + -- Select source account -- + @foreach (var account in Model.Accounts) + { + @account.DisplayLabel + } + + + + + To Account + + -- Select destination account -- + @foreach (var account in Model.Accounts) + { + @account.DisplayLabel + } + + + + + + + + Date + + + + + Amount + + + + + + + Memo (optional) + + Optional note to help you identify this transfer + + + + + Category (optional) + + Defaults to "Transfer" if left blank + + + + + + Transfer + @foreach (var cat in Model.AvailableCategories) + { + @cat + } + + + + Create Transfer + Cancel + + + + + + + + How Transfers Work + + + + Creates two linked transactions to maintain balance accuracy + Source account gets a debit (money out) + Destination account gets a credit (money in) + Both transactions are linked and can be identified as transfers + Transfers can be filtered out when viewing spending reports + + + + + + +@section Scripts { + +} diff --git a/MoneyMap/Pages/CreateTransfer.cshtml.cs b/MoneyMap/Pages/CreateTransfer.cshtml.cs new file mode 100644 index 0000000..9dedbf4 --- /dev/null +++ b/MoneyMap/Pages/CreateTransfer.cshtml.cs @@ -0,0 +1,147 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using System.ComponentModel.DataAnnotations; + +namespace MoneyMap.Pages +{ + public class CreateTransferModel : PageModel + { + private readonly MoneyMapContext _db; + + public CreateTransferModel(MoneyMapContext db) + { + _db = db; + } + + [BindProperty] + [Required(ErrorMessage = "Please select a source account")] + public int FromAccountId { get; set; } + + [BindProperty] + [Required(ErrorMessage = "Please select a destination account")] + public int ToAccountId { get; set; } + + [BindProperty] + [Required] + public DateTime Date { get; set; } = DateTime.Today; + + [BindProperty] + [Required] + [Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than 0")] + public decimal Amount { get; set; } + + [BindProperty] + [MaxLength(500)] + public string Memo { get; set; } = string.Empty; + + [BindProperty] + [MaxLength(100)] + public string Category { get; set; } = string.Empty; + + public List Accounts { get; set; } = new(); + public List AvailableCategories { get; set; } = new(); + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync() + { + await LoadDataAsync(); + return Page(); + } + + public async Task OnPostAsync() + { + // Custom validation + if (FromAccountId == ToAccountId) + { + ModelState.AddModelError(string.Empty, "Source and destination accounts must be different"); + } + + if (!ModelState.IsValid) + { + await LoadDataAsync(); + return Page(); + } + + // Verify both accounts exist + var fromAccount = await _db.Accounts.FindAsync(FromAccountId); + var toAccount = await _db.Accounts.FindAsync(ToAccountId); + + if (fromAccount == null || toAccount == null) + { + ErrorMessage = "One or both accounts not found"; + await LoadDataAsync(); + return Page(); + } + + // Use "Transfer" as default category if not specified + var transferCategory = string.IsNullOrWhiteSpace(Category) ? "Transfer" : Category.Trim(); + var transferMemo = string.IsNullOrWhiteSpace(Memo) + ? $"Transfer to {toAccount.DisplayLabel}" + : Memo.Trim(); + + // Create the debit transaction (source account - money out) + var debitTransaction = new Transaction + { + Date = Date, + Name = $"Transfer to {toAccount.DisplayLabel}", + Memo = transferMemo, + Amount = -Amount, // Negative for debit + Category = transferCategory, + AccountId = FromAccountId, + TransferToAccountId = ToAccountId, + CardId = null // Transfers don't use cards + }; + + // Create the credit transaction (destination account - money in) + var creditTransaction = new Transaction + { + Date = Date, + Name = $"Transfer from {fromAccount.DisplayLabel}", + Memo = transferMemo, + Amount = Amount, // Positive for credit + Category = transferCategory, + AccountId = ToAccountId, + TransferToAccountId = FromAccountId, // Links back to source + CardId = null + }; + + _db.Transactions.Add(debitTransaction); + _db.Transactions.Add(creditTransaction); + + try + { + await _db.SaveChangesAsync(); + SuccessMessage = $"Transfer of {Amount:C} from {fromAccount.DisplayLabel} to {toAccount.DisplayLabel} created successfully!"; + return RedirectToPage("/Transactions"); + } + catch (Exception ex) + { + ErrorMessage = $"Failed to create transfer: {ex.Message}"; + await LoadDataAsync(); + return Page(); + } + } + + private async Task LoadDataAsync() + { + // Load accounts and order in memory by computed property + var accounts = await _db.Accounts.ToListAsync(); + Accounts = accounts.OrderBy(a => a.DisplayLabel).ToList(); + + AvailableCategories = await _db.Transactions + .Select(t => t.Category ?? "") + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Distinct() + .OrderBy(c => c) + .ToListAsync(); + } + } +}